From 265448b20f6dd1f8701eea9e29c47fd90f9b74be Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Fri, 19 Feb 2021 11:52:48 +0000 Subject: [PATCH] Bug 20028: Add Patron Export REST controller This patch adds a REST controller for patron data exporting. To test: 1. prove t/db_dependent/api/v1/patrons_export.t Sponsored-by: The National Library of Finland --- Koha/REST/V1/Patrons/Export.pm | 114 +++++++++++++++ t/db_dependent/api/v1/patrons_export.t | 184 ++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 Koha/REST/V1/Patrons/Export.pm diff --git a/Koha/REST/V1/Patrons/Export.pm b/Koha/REST/V1/Patrons/Export.pm new file mode 100644 index 0000000000..bca43420b1 --- /dev/null +++ b/Koha/REST/V1/Patrons/Export.pm @@ -0,0 +1,114 @@ +package Koha::REST::V1::Patrons::Export; + +# 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 Mojo::Base 'Mojolicious::Controller'; + +use Koha::Patrons; + +use Scalar::Util qw(blessed); +use Try::Tiny; + +=head1 NAME + +Koha::REST::V1::Patrons::Export + +=head1 API + +=head2 Methods + +=head3 get + +Controller method that gets patron's related data, permission driven + +=cut + +sub get { + + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->validation->param('patron_id') ); + my $body = $c->validation->param('body'); + + return try { + + unless ($patron) { + return $c->render( status => 404, openapi => { + error => "Patron not found." + } ); + } + + unless ( C4::Context->preference('AllowGDPRPatronExport') ) { + return $c->render( + status => 403, + openapi => { error => "Configuration prevents patron data export" } + ); + } + + my $export = $patron->export; + + return $c->render( status => 200, openapi => $export ); + } + catch { + $c->unhandled_exception($_); + }; +} + +=head3 get_public + +Controller method that gets patron's related data, for unprivileged users + +=cut + +sub get_public { + + my $c = shift->openapi->valid_input or return; + + my $body = $c->validation->param('body'); + my $patron_id = $c->validation->param('patron_id'); + + return try { + + unless ( C4::Context->preference('AllowGDPRPatronExport') ) { + return $c->render( + status => 403, + openapi => { error => "Configuration prevents patron data export" } + ); + } + + my $user = $c->stash('koha.user'); + + unless ( $user->borrowernumber == $patron_id ) { + return $c->render( + status => 403, + openapi => { + error => "Accessing other patron's data is forbidden" + } + ); + } + + my $export = $user->export; + + return $c->render( status => 200, openapi => $export ); + } + catch { + $c->unhandled_exception($_); + }; +} + +1; diff --git a/t/db_dependent/api/v1/patrons_export.t b/t/db_dependent/api/v1/patrons_export.t index 3e5e35e5b1..86a3d9111f 100755 --- a/t/db_dependent/api/v1/patrons_export.t +++ b/t/db_dependent/api/v1/patrons_export.t @@ -17,7 +17,7 @@ use Modern::Perl; -use Test::More tests => 1; +use Test::More tests => 3; use Test::Mojo; @@ -29,8 +29,80 @@ my $builder = t::lib::TestBuilder->new; t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); +# this is recommended because generation of test data may take a long time and +# Mojolicious would otherwise die with "Premature connection close" +$ENV{MOJO_INACTIVITY_TIMEOUT} = 120; + my $t = Test::Mojo->new('Koha::REST::V1'); +subtest 'get() tests' => sub { + + plan tests => 7; + + $schema->storage->txn_begin; + unauthorized_access_tests('GET', -1, undef); + $schema->storage->txn_rollback; + + $schema->storage->txn_begin; + + my $generated = generate_test_data(); + my $patron = $generated->{patron}; + my $test_objects = $generated->{test_objects}; + + my $password = '12345'; + my $librarian = $builder->build_object({ + class => 'Koha::Patrons', + value => { flags => 2**4 } # borrowers flag = 4 + }); + $librarian->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $librarian->userid; + + t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 1 ); + my $ret = $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id + . '/export') + ->status_is(200) + ->json_is($test_objects); + + t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 0 ); + $ret = $t->get_ok("//$userid:$password@/api/v1/patrons/" + . $patron->id . '/export') + ->status_is(403) + ->json_is('/error', 'Configuration prevents patron data export'); + + $schema->storage->txn_rollback; +}; + +subtest 'get_public() tests' => sub { + + plan tests => 7; + + $schema->storage->txn_begin; + unauthorized_access_tests('GET', -1, undef, 1); + $schema->storage->txn_rollback; + + $schema->storage->txn_begin; + + my $generated = generate_test_data(); + my $patron = $generated->{patron}; + my $test_objects = $generated->{test_objects}; + my $userid = $patron->userid; + my $password = $patron->{cleartext_password}; + + t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 1 ); + my $ret = $t->get_ok("//$userid:$password@/api/v1/public/patrons/" + . $patron->id . '/export') + ->status_is(200) + ->json_is($test_objects); + + t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 0 ); + $ret = $t->get_ok("//$userid:$password@/api/v1/public/patrons/" + . $patron->id . '/export') + ->status_is(403) + ->json_is('/error', 'Configuration prevents patron data export'); + + $schema->storage->txn_rollback; +}; + subtest 'validate patrons_export OpenAPI definition' => sub { my @related_sources = _get_related_sources(); @@ -78,3 +150,113 @@ sub _get_related_sources { my @sorted = sort keys %$sources; return @sorted; } + +sub generate_test_data { + my @related_sources = _get_related_sources(); + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + } + ); + + my $password = '12345'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + $patron->discard_changes; + $patron->{cleartext_password} = $password; + my $test_objects = { + 'Borrower' => $patron->unblessed, + }; + + my $limit_data = 5; # build this many random test objects + my $generated_data = 0; + + foreach my $src ( @related_sources ) { + next if $src eq 'Borrower'; # Borrower is a hashref, + $test_objects->{$src} = []; # the others are arrayrefs + } + + my $result_source = Koha::Patron->new->_result()->result_source; + foreach my $rel ( Koha::Patron->new->_result()->relationships() ) { + if ($generated_data >= $limit_data) { + last; + } + + my $related_source = $result_source->related_source( $rel ); + my $source_name = $related_source->source_name; + + my $info = $result_source->relationship_info( $rel ); + + # We are not interested in the "belongs_to" relationship of borrowers. + # These are tables like branches, categories and sms_provider. + if ( $info->{'attrs'}->{'is_depends_on'} ) { + next; + } + + ( my $rel_col = (keys %{$info->{'cond'}})[0] ) =~ s/^foreign\.//; + + # Generate test data into related tables + my $built; + if ( $related_source->result_class->can('koha_objects_class') ) { + $built = $builder->build_object( + { + class => $related_source->result_class->koha_objects_class, + value => { $rel_col => $patron->borrowernumber } + } + ); + $built = $built->unblessed; + } else { + $built = $builder->build( + { + source => $source_name, + value => { $rel_col => $patron->borrowernumber } + } + ); + } + push @{ $test_objects->{$related_source->source_name} }, $built; + $generated_data++; + } + + # Generate test data into a random table. Just one because test data + # generation takes so long. + $builder->build( { + source => (keys %$test_objects)[rand keys %$test_objects] + } ); + + return { + patron => $patron, + test_objects => $test_objects + }; +} + +# Centralized tests for 401s and 403s assuming the endpoint requires +# borrowers flag for access +sub unauthorized_access_tests { + my ($verb, $patron_id, $json, $public) = @_; + + my $endpoint = '/api/v1/' . ( $public ? 'public/' : '' ) . 'patrons'; + $endpoint .= ($patron_id) ? "/$patron_id/export" : ''; + + subtest 'unauthorized access tests' => sub { + plan tests => 5; + + my $verb_ok = lc($verb) . '_ok'; + + $t->$verb_ok($endpoint => json => $json) + ->status_is(401); + + my $unauthorized_patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password = "12345"; + $unauthorized_patron->set_password( + { password => $password, skip_validation => 1 } ); + my $unauth_userid = $unauthorized_patron->userid; + + $t->$verb_ok( "//$unauth_userid:$password\@$endpoint" => json => $json ) + ->status_is(403) + ->json_has('/required_permissions'); + }; +} -- 2.25.1