From f3e45a774520c198f11c7649810d3cbd76b6e1b5 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Sun, 22 Feb 2026 20:18:43 +0000 Subject: [PATCH] Bug 39224: Unit tests for identity provider object classes Adds unit tests for the new Koha object classes: - t/db_dependent/Koha/Auth/Hostname.t: Tests for hostname CRUD and validation - t/db_dependent/Koha/Auth/Identity/Provider.t: Extended tests for provider with mappings and hostnames - t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t: Tests for field mapping CRUD and validation - t/db_dependent/Auth_with_shibboleth.t: Updated tests for new provider structure Test plan: 1. Run: prove t/db_dependent/Koha/Auth/Hostname.t 2. Run: prove t/db_dependent/Koha/Auth/Identity/Provider.t 3. Run: prove t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t 4. Run: prove t/db_dependent/Auth_with_shibboleth.t Sponsored-by: ByWater Solutions --- t/db_dependent/Auth_with_shibboleth.t | 86 ++++++++-- t/db_dependent/Koha/Auth/Client.t | 60 +++++-- t/db_dependent/Koha/Auth/Client/OAuth.t | 97 +++++++---- t/db_dependent/Koha/Auth/Hostname.t | 139 +++++++++++++++ t/db_dependent/Koha/Auth/Identity/Provider.t | 158 +++++++++++++----- .../Koha/Auth/Identity/Provider/Mapping.t | 140 ++++++++++++++++ t/db_dependent/Koha/Plugins/Auth/Client.t | 56 +++++-- t/db_dependent/Koha/REST/Plugin/Auth/IdP.t | 3 +- 8 files changed, 624 insertions(+), 115 deletions(-) create mode 100755 t/db_dependent/Koha/Auth/Hostname.t create mode 100755 t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t diff --git a/t/db_dependent/Auth_with_shibboleth.t b/t/db_dependent/Auth_with_shibboleth.t index 8a101ac07e6..2dda98b3335 100755 --- a/t/db_dependent/Auth_with_shibboleth.t +++ b/t/db_dependent/Auth_with_shibboleth.t @@ -35,6 +35,13 @@ use t::lib::Dates; use C4::Auth_with_shibboleth qw( shib_ok login_shib_url get_login_shib checkpw_shib ); use Koha::Database; use Koha::DateUtils qw( dt_from_string ); +use Koha::Auth::Hostname; +use Koha::Auth::Hostnames; +use Koha::Auth::Identity::Provider; +use Koha::Auth::Identity::Provider::Hostname; +use Koha::Auth::Identity::Provider::Mapping; +use Koha::Auth::Identity::Provider::SAML2; +use Koha::Auth::Identity::Providers; BEGIN { use_ok( @@ -48,9 +55,7 @@ $schema->storage->txn_begin; my $builder = t::lib::TestBuilder->new; my $logger = t::lib::Mocks::Logger->new(); -# Mock variables my $shibboleth; -change_config( {} ); my $interface = 'opac'; # Mock few preferences @@ -59,12 +64,13 @@ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'teststaff.com' ); t::lib::Mocks::mock_preference( 'EmailFieldPrimary', '' ); t::lib::Mocks::mock_preference( 'EmailFieldPrecedence', 'emailpro' ); -# Mock Context: config, tz and interface +# Mock Context: tz and interface my $context = Test::MockModule->new('C4::Context'); -$context->mock( 'config', sub { return $shibboleth; } ); # easier than caching by Mocks::mock_config $context->mock( 'timezone', sub { return 'local'; } ); $context->mock( 'interface', sub { return $interface; } ); +change_config( {} ); + # Mock Letters: GetPreparedLetter, EnqueueLetter and SendQueuedMessages # We want to test the params my $mocked_letters = Test::MockModule->new('C4::Letters'); @@ -97,20 +103,22 @@ $mocked_letters->mock( # Start testing ---------------------------------------------------------------- subtest "shib_ok tests" => sub { - plan tests => 5; + plan tests => 6; my $result; # correct config, no debug is( shib_ok(), '1', "good config" ); - # bad config, no debug + # bad config, no debug - clear matchpoint on hostname entries + $schema->resultset('IdentityProviderHostname')->update( { matchpoint => undef } ); delete $shibboleth->{matchpoint}; + $logger->clear(); warnings_are { $result = shib_ok() } - [ { carped => 'shibboleth matchpoint not defined' }, ], - "undefined matchpoint = fatal config, warning given"; + [ { carped => 'shibboleth matchpoint not defined' } ], + "matchpoint warning given when no matchpoint set"; is( $result, '0', "bad config" ); - change_config( { matchpoint => 'email' } ); + change_config( { matchpoint => 'email', mapping => { email => { content => 'default@example.com' } } } ); warnings_are { $result = shib_ok() } [ { carped => 'shibboleth matchpoint not mapped' }, ], "unmapped matchpoint = fatal config, warning given"; @@ -119,6 +127,7 @@ subtest "shib_ok tests" => sub { # add test for undefined shibboleth block $logger->clear; change_config( {} ); + is( shib_ok(), '1', 'good config after reset' ); }; subtest "login_shib_url tests" => sub { @@ -279,6 +288,10 @@ subtest "checkpw_shib tests" => sub { ); # sync user + my $saml2_provider = Koha::Auth::Identity::Providers->search( { protocol => 'SAML2' } )->next; + my $saml2_config = $saml2_provider->get_config // {}; + $saml2_config->{sync} = 1; + $saml2_provider->set_config($saml2_config)->store; $shibboleth->{sync} = 1; $ENV{'city'} = 'AnotherCity'; ( $retval, $retcard, $retuserid, $retpatron ) = checkpw_shib($shib_login); @@ -335,7 +348,7 @@ subtest "checkpw_shib tests" => sub { $ENV{'emailpro'} = 'me@myemail.com'; $ENV{branchcode} = $library->branchcode; # needed since T::D::C does no longer hides the FK constraint - checkpw($shib_login); + ( $retval, $retcard, $retuserid, $retpatron ) = checkpw_shib($shib_login); ok( my $new_user_autocreated = Koha::Patrons->find( { userid => 'test43210' } ), "new user found" @@ -424,6 +437,38 @@ $schema->storage->txn_rollback; sub change_config { my $params = shift; + # Remove any existing SAML2 providers + Koha::Auth::Identity::Providers->search( { protocol => 'SAML2' } )->delete; + + my $provider = Koha::Auth::Identity::Provider::SAML2->new( + { + code => 'shibboleth', + description => 'Shibboleth', + enabled => 1, + } + )->store; + + $provider->set_config( + { + autocreate => $params->{autocreate} // 0, + sync => $params->{sync} // 0, + welcome => $params->{welcome} // 0, + } + )->store; + + # Link provider to the test hostname so _get_shib_config can find it + my $test_hostname = 'testopac.com'; + $ENV{SERVER_NAME} = $test_hostname; + my $hostname_obj = Koha::Auth::Hostnames->search( { hostname => $test_hostname } )->next + // $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => $test_hostname } } ); + Koha::Auth::Identity::Provider::Hostname->new( + { + hostname_id => $hostname_obj->hostname_id, + identity_provider_id => $provider->identity_provider_id, + is_enabled => 1, + } + )->store; + my %mapping = ( 'userid' => { 'is' => 'uid' }, 'surname' => { 'is' => 'sn' }, @@ -437,15 +482,32 @@ sub change_config { if ( exists $params->{mapping} ) { $mapping{$_} = $params->{mapping}->{$_} for keys %{ $params->{mapping} }; } + + my $matchpoint = $params->{matchpoint} // 'userid'; + + foreach my $koha_field ( keys %mapping ) { + Koha::Auth::Identity::Provider::Mapping->new( + { + identity_provider_id => $provider->identity_provider_id, + koha_field => $koha_field, + provider_field => $mapping{$koha_field}->{is}, + default_content => $mapping{$koha_field}->{content}, + } + )->store; + } + + $schema->resultset('IdentityProviderHostname') + ->search( { identity_provider_id => $provider->identity_provider_id } ) + ->update( { matchpoint => $matchpoint } ); + $shibboleth = { autocreate => $params->{autocreate} // 0, welcome => $params->{welcome} // 0, sync => $params->{sync} // 0, - matchpoint => $params->{matchpoint} // 'userid', + matchpoint => $matchpoint, mapping => \%mapping, }; - # Change environment too $ENV{'uid'} = "test1234"; $ENV{'sn'} = undef; $ENV{'exp'} = undef; diff --git a/t/db_dependent/Koha/Auth/Client.t b/t/db_dependent/Koha/Auth/Client.t index 47cb73c32c0..358f06848a9 100755 --- a/t/db_dependent/Koha/Auth/Client.t +++ b/t/db_dependent/Koha/Auth/Client.t @@ -45,9 +45,20 @@ subtest 'get_user() tests' => sub { $schema->storage->txn_begin; - my $client = Koha::Auth::Client::OAuth->new; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { matchpoint => 'email' } } ); + my $client = Koha::Auth::Client::OAuth->new; + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostname_obj = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'test.library.com' } } ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { + identity_provider_id => $provider->id, + hostname_id => $hostname_obj->id, + matchpoint => 'email', + } + } + ); my $domain = $builder->build_object( { class => 'Koha::Auth::Identity::Provider::Domains', @@ -59,12 +70,26 @@ subtest 'get_user() tests' => sub { ); my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { email => 'patron@test.com' } } ); t::lib::Mocks::mock_userenv( { patron => $patron } ); - my $mapping = { - email => 'electronic_mail', - firstname => 'given_name', - surname => 'family_name' - }; - $provider->set_mapping($mapping)->store; + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'electronic_mail' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'firstname', provider_field => 'given_name' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'surname', provider_field => 'family_name' } + } + ); my $id_token = 'header.' . encode_base64url( encode_json( @@ -78,7 +103,8 @@ subtest 'get_user() tests' => sub { my $data = { id_token => $id_token }; my ( $resolved_patron, $mapped_data, $resolved_domain ) = - $client->get_user( { provider => $provider->code, data => $data, interface => 'opac' } ); + $client->get_user( + { provider => $provider->code, data => $data, interface => 'opac', hostname => 'test.library.com' } ); is_deeply( $resolved_patron->to_api( { user => $patron } ), $patron->to_api( { user => $patron } ), 'Patron correctly retrieved' @@ -95,10 +121,9 @@ subtest 'get_valid_domain_config() tests' => sub { $schema->storage->txn_begin; - my $client = Koha::Auth::Client->new; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { matchpoint => 'email' } } ); - my $domain1 = $builder->build_object( + my $client = Koha::Auth::Client->new; + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $domain1 = $builder->build_object( { class => 'Koha::Auth::Identity::Provider::Domains', value => { identity_provider_id => $provider->id, domain => '', allow_opac => 0, allow_staff => 0 } @@ -200,10 +225,9 @@ subtest 'has_valid_domain_config() tests' => sub { plan tests => 2; $schema->storage->txn_begin; - my $client = Koha::Auth::Client->new; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { matchpoint => 'email' } } ); - my $domain1 = $builder->build_object( + my $client = Koha::Auth::Client->new; + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $domain1 = $builder->build_object( { class => 'Koha::Auth::Identity::Provider::Domains', value => { identity_provider_id => $provider->id, domain => '', allow_opac => 1, allow_staff => 0 } diff --git a/t/db_dependent/Koha/Auth/Client/OAuth.t b/t/db_dependent/Koha/Auth/Client/OAuth.t index 0cca7126a22..c0839f97754 100755 --- a/t/db_dependent/Koha/Auth/Client/OAuth.t +++ b/t/db_dependent/Koha/Auth/Client/OAuth.t @@ -50,19 +50,32 @@ subtest '_get_data_and_patron() with id_token tests' => sub { ); # Create provider with email matchpoint - my $provider = $builder->build_object( + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostname_obj = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'test.library.com' } } ); + $builder->build_object( { - class => 'Koha::Auth::Identity::Providers', - value => { - matchpoint => 'email', - mapping => encode_json( - { - email => 'mail', - firstname => 'given_name', - surname => 'family_name' - } - ) - } + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { identity_provider_id => $provider->id, hostname_id => $hostname_obj->id, matchpoint => 'email' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'mail' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'firstname', provider_field => 'given_name' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'surname', provider_field => 'family_name' } } ); @@ -83,7 +96,8 @@ subtest '_get_data_and_patron() with id_token tests' => sub { { provider => $provider, data => $data, - config => $config + config => $config, + hostname => 'test.library.com', } ); @@ -112,18 +126,26 @@ subtest '_get_data_and_patron() with userinfo_url tests' => sub { ); # Create provider with email matchpoint - my $provider = $builder->build_object( + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostname_obj = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'test2.library.com' } } ); + $builder->build_object( { - class => 'Koha::Auth::Identity::Providers', - value => { - matchpoint => 'email', - mapping => encode_json( - { - email => 'email', - firstname => 'first_name' - } - ) - } + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { identity_provider_id => $provider->id, hostname_id => $hostname_obj->id, matchpoint => 'email' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'email' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'firstname', provider_field => 'first_name' } } ); @@ -179,7 +201,8 @@ subtest '_get_data_and_patron() with userinfo_url tests' => sub { { provider => $provider, data => $data, - config => $config + config => $config, + hostname => 'test2.library.com', } ); @@ -205,7 +228,8 @@ subtest '_get_data_and_patron() with userinfo_url tests' => sub { { provider => $provider, data => $data, - config => $config + config => $config, + hostname => 'test2.library.com', } ); @@ -223,13 +247,19 @@ subtest '_get_data_and_patron() no patron found tests' => sub { my $client = Koha::Auth::Client::OAuth->new; # Create provider - my $provider = $builder->build_object( + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostname_obj = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'test3.library.com' } } ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { identity_provider_id => $provider->id, hostname_id => $hostname_obj->id, matchpoint => 'email' } + } + ); + $builder->build_object( { - class => 'Koha::Auth::Identity::Providers', - value => { - matchpoint => 'email', - mapping => encode_json( { email => 'mail' } ) - } + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'mail' } } ); @@ -245,7 +275,8 @@ subtest '_get_data_and_patron() no patron found tests' => sub { { provider => $provider, data => $data, - config => $config + config => $config, + hostname => 'test3.library.com', } ); diff --git a/t/db_dependent/Koha/Auth/Hostname.t b/t/db_dependent/Koha/Auth/Hostname.t new file mode 100755 index 00000000000..c66f993408b --- /dev/null +++ b/t/db_dependent/Koha/Auth/Hostname.t @@ -0,0 +1,139 @@ +#!/usr/bin/perl + +# Copyright Koha Community 2026 +# +# This file is part of Koha +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::NoWarnings; +use Test::More tests => 4; + +use Koha::Auth::Hostname; +use Koha::Auth::Hostnames; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'sync_from_syspref() tests' => sub { + + plan tests => 12; + + $schema->storage->txn_begin; + + Koha::Auth::Hostnames->search->delete; + + # OPACBaseURL maps to hostname_id=1 + t::lib::Mocks::mock_preference( 'OPACBaseURL', 'https://opac.example.com' ); + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'https://opac.example.com' ); + + my $opac = Koha::Auth::Hostnames->find(1); + ok( $opac, 'OPACBaseURL creates hostname_id=1' ); + is( $opac->hostname, 'opac.example.com', 'hostname_id=1 has correct hostname' ); + + # staffClientBaseURL maps to hostname_id=2 + Koha::Auth::Hostname->sync_from_syspref( 'staffClientBaseURL', 'https://staff.example.com' ); + + my $staff = Koha::Auth::Hostnames->find(2); + ok( $staff, 'staffClientBaseURL creates hostname_id=2' ); + is( $staff->hostname, 'staff.example.com', 'hostname_id=2 has correct hostname' ); + + # Updating the URL changes the stored hostname in-place + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'https://newopac.example.com' ); + $opac->discard_changes; + is( $opac->hostname, 'newopac.example.com', 'hostname_id=1 updated when OPACBaseURL changes' ); + is( Koha::Auth::Hostnames->search( { hostname_id => 1 } )->count, 1, 'Still only one row for hostname_id=1' ); + + # Idempotent: calling again with the same value is a no-op + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'https://newopac.example.com' ); + is( + Koha::Auth::Hostnames->search( { hostname => 'newopac.example.com' } )->count, 1, + 'Idempotent: no duplicate created' + ); + + # http:// URLs are supported + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'http://opac.example.org' ); + $opac->discard_changes; + is( $opac->hostname, 'opac.example.org', 'http:// URL extracted correctly' ); + + # Port numbers are stripped + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'https://opac.example.net:8080/path' ); + $opac->discard_changes; + is( $opac->hostname, 'opac.example.net', 'Port number stripped from hostname' ); + + # Empty value is a no-op + my $hostname_before = $opac->hostname; + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', '' ); + $opac->discard_changes; + is( $opac->hostname, $hostname_before, 'Empty value leaves existing hostname unchanged' ); + + # Malformed URL (no protocol) is a no-op + Koha::Auth::Hostname->sync_from_syspref( 'OPACBaseURL', 'not-a-valid-url' ); + $opac->discard_changes; + is( $opac->hostname, $hostname_before, 'Malformed URL leaves existing hostname unchanged' ); + + # Unknown preference name is ignored + Koha::Auth::Hostname->sync_from_syspref( 'SomeOtherPref', 'https://other.example.com' ); + is( + Koha::Auth::Hostnames->search( { hostname => 'other.example.com' } )->count, + 0, 'Unknown syspref name is ignored' + ); + + $schema->storage->txn_rollback; +}; + +subtest 'sync_from_sysprefs() tests' => sub { + + plan tests => 4; + + $schema->storage->txn_begin; + + Koha::Auth::Hostnames->search->delete; + + t::lib::Mocks::mock_preference( 'OPACBaseURL', 'https://opac.example.com' ); + t::lib::Mocks::mock_preference( 'staffClientBaseURL', 'https://staff.example.com' ); + + Koha::Auth::Hostname->sync_from_sysprefs; + + my $opac = Koha::Auth::Hostnames->find(1); + my $staff = Koha::Auth::Hostnames->find(2); + + ok( $opac, 'hostname_id=1 exists after sync_from_sysprefs' ); + is( $opac->hostname, 'opac.example.com', 'hostname_id=1 has OPAC hostname' ); + + ok( $staff, 'hostname_id=2 exists after sync_from_sysprefs' ); + is( $staff->hostname, 'staff.example.com', 'hostname_id=2 has staff hostname' ); + + $schema->storage->txn_rollback; +}; + +subtest 'Koha::Auth::Hostnames type tests' => sub { + + plan tests => 2; + + $schema->storage->txn_begin; + + my $hostname = $builder->build_object( { class => 'Koha::Auth::Hostnames' } ); + is( ref($hostname), 'Koha::Auth::Hostname', 'build_object returns correct object type' ); + + my $hostnames = Koha::Auth::Hostnames->search( { hostname_id => $hostname->hostname_id } ); + is( ref($hostnames), 'Koha::Auth::Hostnames', 'search returns correct collection type' ); + + $schema->storage->txn_rollback; +}; diff --git a/t/db_dependent/Koha/Auth/Identity/Provider.t b/t/db_dependent/Koha/Auth/Identity/Provider.t index 8fe84cb8982..04e1decb374 100755 --- a/t/db_dependent/Koha/Auth/Identity/Provider.t +++ b/t/db_dependent/Koha/Auth/Identity/Provider.t @@ -20,13 +20,17 @@ use Modern::Perl; use Test::NoWarnings; -use Test::More tests => 7; +use Test::More tests => 8; use Test::MockModule; use Test::Exception; use JSON qw(encode_json); +use Koha::Auth::Hostnames; +use Koha::Auth::Identity::Provider; +use Koha::Auth::Identity::Provider::Hostnames; +use Koha::Auth::Identity::Provider::Mappings; use Koha::Auth::Identity::Providers; use t::lib::TestBuilder; @@ -88,7 +92,6 @@ subtest 'set_config() tests' => sub { my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { protocol => 'OIDC' } } ); - $provider = $provider->upgrade_class; my $config = { key => 'key', @@ -114,7 +117,6 @@ subtest 'set_config() tests' => sub { my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { protocol => 'OAuth' } } ); - $provider = $provider->upgrade_class; my $config = { key => 'key', @@ -135,15 +137,15 @@ subtest 'set_config() tests' => sub { is_deeply( $provider->get_config, $config, 'Configuration stored correctly' ); }; - subtest 'Unsupported protocol tests' => sub { + subtest 'Base class (unsupported protocol) tests' => sub { plan tests => 2; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { protocol => 'CAS' } } ); + # Cannot build in DB with invalid protocol; instantiate base class directly + my $provider = Koha::Auth::Identity::Provider->new( { protocol => 'OAuth' } ); - throws_ok { $provider->set_config() } - 'Koha::Exception', 'Exception thrown on unsupported protocol'; + throws_ok { $provider->set_config( {} ) } + 'Koha::Exception', 'Exception thrown when calling set_config on base class'; like( "$@", qr/This method needs to be subclassed/, 'Message is correct' ); }; @@ -151,48 +153,66 @@ subtest 'set_config() tests' => sub { $schema->storage->txn_rollback; }; -subtest 'get_mapping() tests' => sub { +subtest 'mappings() tests' => sub { - plan tests => 2; + plan tests => 3; $schema->storage->txn_begin; - my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { config => '{' } } ); - - throws_ok { $provider->get_mapping() } - 'Koha::Exceptions::Object::BadValue', 'Expected exception thrown on bad JSON'; - - my $mapping = { some => 'value', and => 'another' }; - $provider->mapping( encode_json($mapping) )->store; - - is_deeply( $provider->get_mapping, $mapping, 'Mapping correctly retrieved' ); + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $mappings = $provider->mappings; + + is( ref($mappings), 'Koha::Auth::Identity::Provider::Mappings', 'Type is correct' ); + is( $mappings->count, 0, 'No mappings defined' ); + + $builder->build( + { + source => 'IdentityProviderMapping', + value => { identity_provider_id => $provider->id, koha_field => 'userid', provider_field => 'uid' } + } + ); + $builder->build( + { + source => 'IdentityProviderMapping', + value => { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'mail' } + } + ); + + is( $provider->mappings->count, 2, 'The provider has 2 mappings defined' ); $schema->storage->txn_rollback; }; -subtest 'set_mapping() tests' => sub { +subtest 'hostnames() tests' => sub { - plan tests => 1; + plan tests => 3; $schema->storage->txn_begin; - my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostnames = $provider->hostnames; - my $mapping = { some => 'value', and => 'another' }; - $provider->set_mapping($mapping)->store; + is( ref($hostnames), 'Koha::Auth::Identity::Provider::Hostnames', 'Type is correct' ); + is( $hostnames->count, 0, 'No hostnames defined' ); + + $builder->build_object( + { class => 'Koha::Auth::Identity::Provider::Hostnames', value => { identity_provider_id => $provider->id } } ); + $builder->build_object( + { class => 'Koha::Auth::Identity::Provider::Hostnames', value => { identity_provider_id => $provider->id } } ); - is_deeply( $provider->get_mapping, $mapping, 'Mapping correctly retrieved' ); + is( $provider->hostnames->count, 2, 'The provider has 2 hostnames defined' ); $schema->storage->txn_rollback; }; -subtest 'upgrade_class() tests' => sub { +subtest 'polymorphic_retrieval() tests' => sub { - plan tests => 5; + plan tests => 7; $schema->storage->txn_begin; - my $mapping = Koha::Auth::Identity::Provider::protocol_to_class_mapping; + my $providers = Koha::Auth::Identity::Providers->new; + my $mapping = $providers->_polymorphic_map; my @protocols = keys %{$mapping}; foreach my $protocol (@protocols) { @@ -204,20 +224,82 @@ subtest 'upgrade_class() tests' => sub { } ); - is( ref($provider), 'Koha::Auth::Identity::Provider', "Base class used for $protocol" ); + is( ref($provider), $mapping->{$protocol}, "build_object returns correct subclass for $protocol" ); - # upgrade - $provider = $provider->upgrade_class; - is( - ref($provider), $mapping->{$protocol}, - "Class upgraded to " . $mapping->{$protocol} . "for protocol $protocol" - ); + my $found = Koha::Auth::Identity::Providers->find( $provider->id ); + is( ref($found), $mapping->{$protocol}, "Providers->find returns correct subclass for $protocol" ); } my $provider = Koha::Auth::Identity::Provider->new( { protocol => 'Invalid' } ); - throws_ok { $provider->upgrade } - 'Koha::Exception', - 'Exception throw on invalid protocol'; + throws_ok { $provider->set_config( {} ) } 'Koha::Exception', 'Exception thrown calling set_config on base class'; + + $schema->storage->txn_rollback; +}; + +subtest 'find_forced_provider() tests' => sub { + + plan tests => 8; + + $schema->storage->txn_begin; + + # No hostname argument returns undef + is( Koha::Auth::Identity::Providers->find_forced_provider(undef), undef, 'Returns undef when hostname is undef' ); + is( + Koha::Auth::Identity::Providers->find_forced_provider(''), undef, + 'Returns undef when hostname is empty string' + ); + + my $provider = $builder->build_object( + { class => 'Koha::Auth::Identity::Providers', value => { protocol => 'OIDC', enabled => 1 } } ); + + my $hostname_record = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'sso.example.com' } } ); + + # Hostname linked but force_sso=0 (default) + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { + identity_provider_id => $provider->id, + hostname_id => $hostname_record->hostname_id, + is_enabled => 1, + force_sso => 0, + } + } + ); + is( + Koha::Auth::Identity::Providers->find_forced_provider('sso.example.com'), + undef, 'Returns undef when hostname is linked but force_sso is 0' + ); + + # Update to force_sso=1 but is_enabled=0 on the hostname link + Koha::Auth::Identity::Provider::Hostnames->search( + { identity_provider_id => $provider->id, hostname_id => $hostname_record->hostname_id } ) + ->update( { force_sso => 1, is_enabled => 0 } ); + is( + Koha::Auth::Identity::Providers->find_forced_provider('sso.example.com'), + undef, 'Returns undef when hostname link has is_enabled=0' + ); + + # Enable the hostname link but disable the provider itself + Koha::Auth::Identity::Provider::Hostnames->search( + { identity_provider_id => $provider->id, hostname_id => $hostname_record->hostname_id } ) + ->update( { is_enabled => 1 } ); + $provider->update( { enabled => 0 } ); + is( + Koha::Auth::Identity::Providers->find_forced_provider('sso.example.com'), + undef, 'Returns undef when provider itself is disabled' + ); + + # Re-enable the provider - now all conditions met + $provider->update( { enabled => 1 } ); + my $forced = Koha::Auth::Identity::Providers->find_forced_provider('sso.example.com'); + ok( $forced, 'Returns provider when hostname linked with force_sso=1, is_enabled=1, and provider enabled=1' ); + is( + ref($forced), 'Koha::Auth::Identity::Provider::OIDC', + 'Returned object is the correct Identity Provider subclass' + ); + is( $forced->id, $provider->id, 'Returns the correct provider' ); $schema->storage->txn_rollback; }; diff --git a/t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t b/t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t new file mode 100755 index 00000000000..b15a4e48b02 --- /dev/null +++ b/t/db_dependent/Koha/Auth/Identity/Provider/Mapping.t @@ -0,0 +1,140 @@ +#!/usr/bin/perl + +# Copyright Koha Community 2026 +# +# This file is part of Koha +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::NoWarnings; +use Test::More tests => 3; +use Test::Exception; + +use Koha::Auth::Hostnames; +use Koha::Auth::Identity::Provider::Hostname; +use Koha::Auth::Identity::Provider::Mapping; +use Koha::Auth::Identity::Provider::Mappings; +use Koha::Auth::Identity::Providers; + +use t::lib::TestBuilder; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'Mapping::store() tests' => sub { + + plan tests => 4; + + $schema->storage->txn_begin; + + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + + # Must throw if neither provider_field nor default_content is set + throws_ok { + Koha::Auth::Identity::Provider::Mapping->new( + { + identity_provider_id => $provider->id, + koha_field => 'userid', + } + )->store; + } + 'Koha::Exceptions::MissingParameter', + 'Exception thrown when neither provider_field nor default_content is provided'; + + like( $@->parameter, qr/provider_field or default_content/, 'Exception parameter message is correct' ); + + # Succeeds with provider_field only + my $m1 = Koha::Auth::Identity::Provider::Mapping->new( + { + identity_provider_id => $provider->id, + koha_field => 'userid', + provider_field => 'uid', + } + )->store; + ok( $m1, 'store() succeeds when only provider_field is supplied' ); + + # Succeeds with default_content only + my $m2 = Koha::Auth::Identity::Provider::Mapping->new( + { + identity_provider_id => $provider->id, + koha_field => 'categorycode', + default_content => 'PT', + } + )->store; + ok( $m2, 'store() succeeds when only default_content is supplied' ); + + $schema->storage->txn_rollback; +}; + +subtest 'Mappings::as_auth_mapping() tests' => sub { + + plan tests => 3; + + $schema->storage->txn_begin; + + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + + my $mapping = $provider->mappings->as_auth_mapping; + is_deeply( $mapping, {}, 'Returns empty hashref when provider has no mappings' ); + + # Create mappings: one with provider_field, one with default_content only + $builder->build( + { + source => 'IdentityProviderMapping', + value => { + identity_provider_id => $provider->id, + koha_field => 'userid', + provider_field => 'uid', + default_content => undef, + } + } + ); + $builder->build( + { + source => 'IdentityProviderMapping', + value => { + identity_provider_id => $provider->id, + koha_field => 'categorycode', + provider_field => undef, + default_content => 'PT', + } + } + ); + + $mapping = $provider->mappings->as_auth_mapping; + is_deeply( + $mapping, + { + userid => { is => 'uid', content => undef }, + categorycode => { is => undef, content => 'PT' }, + }, + 'Returns correct mapping hashref with is/content keys for each koha_field' + ); + + # Matchpoint is stored on the hostname association, not the provider + my $hostname_obj = $builder->build_object( + { class => 'Koha::Auth::Hostnames', value => { hostname => 'matchpoint.example.com' } } ); + my $hostname_assoc = Koha::Auth::Identity::Provider::Hostname->new( + { + identity_provider_id => $provider->id, + hostname_id => $hostname_obj->hostname_id, + matchpoint => 'userid', + } + )->store; + is( $hostname_assoc->matchpoint, 'userid', 'Hostname matchpoint is stored and retrieved correctly' ); + + $schema->storage->txn_rollback; +}; diff --git a/t/db_dependent/Koha/Plugins/Auth/Client.t b/t/db_dependent/Koha/Plugins/Auth/Client.t index 4356572eb59..1920b27dc07 100755 --- a/t/db_dependent/Koha/Plugins/Auth/Client.t +++ b/t/db_dependent/Koha/Plugins/Auth/Client.t @@ -56,9 +56,20 @@ subtest 'auth_client_get_user hook tests' => sub { # Test Plugin manipulates mapped_data my $plugin = Koha::Plugin::Test->new->enable; - my $client = Koha::Auth::Client::OAuth->new; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { matchpoint => 'email' } } ); + my $client = Koha::Auth::Client::OAuth->new; + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); + my $hostname_obj = + $builder->build_object( { class => 'Koha::Auth::Hostnames', value => { hostname => 'test.library.com' } } ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Hostnames', + value => { + identity_provider_id => $provider->id, + hostname_id => $hostname_obj->id, + matchpoint => 'email', + } + } + ); my $domain = $builder->build_object( { class => 'Koha::Auth::Identity::Provider::Domains', @@ -68,14 +79,34 @@ subtest 'auth_client_get_user hook tests' => sub { } } ); - my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { email => 'patron@test.com' } } ); - my $mapping = { - email => 'electronic_mail', - firstname => 'given_name', - surname => 'family_name', - cardnumber => 'cardnumber', - }; - $provider->set_mapping($mapping)->store; + my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { email => 'patron@test.com' } } ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'email', provider_field => 'electronic_mail' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'firstname', provider_field => 'given_name' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => { identity_provider_id => $provider->id, koha_field => 'surname', provider_field => 'family_name' } + } + ); + $builder->build_object( + { + class => 'Koha::Auth::Identity::Provider::Mappings', + value => + { identity_provider_id => $provider->id, koha_field => 'cardnumber', provider_field => 'cardnumber' } + } + ); my $id_token = 'header.' . encode_base64url( encode_json( @@ -90,7 +121,8 @@ subtest 'auth_client_get_user hook tests' => sub { my $data = { id_token => $id_token }; my ( $resolved_patron, $mapped_data, $resolved_domain ) = - $client->get_user( { provider => $provider->code, data => $data, interface => 'opac' } ); + $client->get_user( + { provider => $provider->code, data => $data, interface => 'opac', hostname => 'test.library.com' } ); is( $mapped_data->{cardnumber}, '12345', 'Plugin manipulated mapped_data successfully' ); isnt( $resolved_patron->borrowernumber, $patron->borrowernumber, 'Plugin changed the resolved patron' ); isnt( $resolved_domain->domain, $domain->domain, 'Plugin changed the resolved domain' ); diff --git a/t/db_dependent/Koha/REST/Plugin/Auth/IdP.t b/t/db_dependent/Koha/REST/Plugin/Auth/IdP.t index 1c91541ee04..db4765eb37e 100755 --- a/t/db_dependent/Koha/REST/Plugin/Auth/IdP.t +++ b/t/db_dependent/Koha/REST/Plugin/Auth/IdP.t @@ -105,8 +105,7 @@ subtest 'auth.register helper' => sub { $patron_to_delete_1->delete; $patron_to_delete_2->delete; - my $provider = - $builder->build_object( { class => 'Koha::Auth::Identity::Providers', value => { matchpoint => 'email' } } ); + my $provider = $builder->build_object( { class => 'Koha::Auth::Identity::Providers' } ); my $domain_1 = $builder->build_object( { -- 2.53.0