diff --git a/api/v1/swagger/definitions/action_log.yaml b/api/v1/swagger/definitions/action_log.yaml index b4d72726f0..719a2622d1 100644 --- a/api/v1/swagger/definitions/action_log.yaml +++ b/api/v1/swagger/definitions/action_log.yaml @@ -52,4 +52,19 @@ properties: - string - "null" description: A diff of the changed object + librarian: + type: + - object + - "null" + description: | + The patron object for the librarian who performed the action + (only present if requested via x-koha-embed) + patron: + type: + - object + - "null" + description: | + Patron object joined on `object` (only meaningful for MEMBERS, + CIRCULATION, FINES and APIKEYS rows; only present if requested + via x-koha-embed) additionalProperties: false diff --git a/api/v1/swagger/paths/action_logs.yaml b/api/v1/swagger/paths/action_logs.yaml index e236092993..abbe580916 100644 --- a/api/v1/swagger/paths/action_logs.yaml +++ b/api/v1/swagger/paths/action_logs.yaml @@ -57,6 +57,17 @@ - $ref: "../swagger.yaml#/parameters/q_param" - $ref: "../swagger.yaml#/parameters/q_body" - $ref: "../swagger.yaml#/parameters/request_id_header" + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - librarian + - patron + collectionFormat: csv responses: "200": description: A list of action logs diff --git a/t/db_dependent/api/v1/action_logs.t b/t/db_dependent/api/v1/action_logs.t index f442bdd472..7bb73ba4fe 100755 --- a/t/db_dependent/api/v1/action_logs.t +++ b/t/db_dependent/api/v1/action_logs.t @@ -18,9 +18,11 @@ use Modern::Perl; use Test::NoWarnings; -use Test::More tests => 2; +use Test::More tests => 6; use Test::Mojo; +use Mojo::JSON qw( encode_json ); + use t::lib::TestBuilder; use t::lib::Mocks; @@ -31,25 +33,27 @@ use Koha::Database; my $schema = Koha::Database->new->schema; my $builder = t::lib::TestBuilder->new; -t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); +t::lib::Mocks::mock_preference( 'AuthSuccessLog', 0 ); +t::lib::Mocks::mock_preference( 'AuthFailureLog', 0 ); +t::lib::Mocks::mock_preference( 'BorrowersLog', 0 ); my $t = Test::Mojo->new('Koha::REST::V1'); -subtest 'list() tests' => sub { - - plan tests => 24; - - $schema->storage->txn_begin; +# Build credentials and an isolated set of seed log entries used by every +# subtest below. +sub build_fixtures { + # Need both `tools` (view_system_logs) and `borrowers` so embedded patron + # fields don't get redacted by Koha::Patron->is_accessible. my $librarian = $builder->build_object( { class => 'Koha::Patrons', - value => { flags => 2**13 } # tools flag = 13 + value => { flags => 2**13 + 2**4 } # tools + borrowers } ); my $password = 'thePassword123'; $librarian->set_password( { password => $password, skip_validation => 1 } ); - my $userid = $librarian->userid; my $patron = $builder->build_object( { @@ -58,58 +62,157 @@ subtest 'list() tests' => sub { } ); $patron->set_password( { password => $password, skip_validation => 1 } ); + + Koha::ActionLogs->search->delete; + t::lib::Mocks::mock_userenv( { borrowernumber => $librarian->borrowernumber } ); + + return ( $librarian, $patron, $password ); +} + +subtest 'authorization' => sub { + plan tests => 4; + $schema->storage->txn_begin; + + my ( $librarian, $patron, $password ) = build_fixtures(); + my $userid = $librarian->userid; my $unauth_userid = $patron->userid; - # Delete all logs after patron creation (building patrons can generate log entries) - Koha::ActionLogs->search->delete; + $t->get_ok("//$unauth_userid:$password@/api/v1/action_logs") + ->status_is( 403, 'forbidden without view_system_logs' ); + $t->get_ok("//$userid:$password@/api/v1/action_logs")->status_is( 200, 'allowed with tools/view_system_logs' ); - ## Unauthorized access - $t->get_ok("//$unauth_userid:$password@/api/v1/action_logs")->status_is(403); + $schema->storage->txn_rollback; +}; - # Delete again after auth attempts (login can generate AUTH logs) - Koha::ActionLogs->search->delete; +subtest 'simple typed filters' => sub { + plan tests => 13; + $schema->storage->txn_begin; - ## Authorized user tests - # No logs, so empty array should be returned - $t->get_ok("//$userid:$password@/api/v1/action_logs")->status_is(200)->json_is( [] ); + my ( $librarian, $patron, $password ) = build_fixtures(); + my $userid = $librarian->userid; - # Delete any logs created by the successful auth above - Koha::ActionLogs->search->delete; - - # Create some log entries using a test-specific module to avoid collisions - t::lib::Mocks::mock_userenv( { borrowernumber => $librarian->borrowernumber } ); logaction( 'MEMBERS', 'MODIFY', $patron->borrowernumber, 'test info', 'INTRANET' ); logaction( 'CIRCULATION', 'ISSUE', $patron->borrowernumber, 'circ info', 'INTRANET' ); logaction( 'CATALOGUING', 'ADD', 42, 'biblio added', 'COMMANDLINE' ); - # Should return all three logs - $t->get_ok("//$userid:$password@/api/v1/action_logs")->status_is(200); - my $logs = $t->tx->res->json; - is( scalar @$logs, 3, 'Three log entries returned' ); - - # Filter by module $t->get_ok("//$userid:$password@/api/v1/action_logs?module=MEMBERS")->status_is(200); - $logs = $t->tx->res->json; - is( scalar @$logs, 1, 'One MEMBERS log entry returned' ); - is( $logs->[0]->{module}, 'MEMBERS', 'Correct module returned' ); - is( $logs->[0]->{info}, 'test info', 'Correct info returned' ); + my $logs = $t->tx->res->json; + is( scalar @$logs, 1, 'one MEMBERS row' ); + is( $logs->[0]->{module}, 'MEMBERS', 'correct module returned' ); + is( $logs->[0]->{info}, 'test info', 'correct info returned' ); - # Filter by action $t->get_ok("//$userid:$password@/api/v1/action_logs?action=ADD")->status_is(200); - $logs = $t->tx->res->json; - is( scalar @$logs, 1, 'One ADD action log entry returned' ); - is( $logs->[0]->{action}, 'ADD', 'Correct action returned' ); + is( $t->tx->res->json->[0]->{action}, 'ADD', 'action filter works' ); - # Filter by interface $t->get_ok("//$userid:$password@/api/v1/action_logs?interface=COMMANDLINE")->status_is(200); - $logs = $t->tx->res->json; - is( scalar @$logs, 1, 'One COMMANDLINE log entry returned' ); - is( $logs->[0]->{interface}, 'COMMANDLINE', 'Correct interface returned' ); + is( $t->tx->res->json->[0]->{interface}, 'COMMANDLINE', 'interface filter works' ); - # Warn on unsupported query parameter $t->get_ok("//$userid:$password@/api/v1/action_logs?bork=borkbork") - ->status_is(400) - ->json_is( [ { path => '/query/bork', message => 'Malformed query string' } ] ); + ->status_is( 400, 'unsupported query param rejected' ); $schema->storage->txn_rollback; }; + +subtest 'q DSL: multi-value, like, range' => sub { + plan tests => 13; + $schema->storage->txn_begin; + + my ( $librarian, $patron, $password ) = build_fixtures(); + my $userid = $librarian->userid; + + logaction( 'MEMBERS', 'MODIFY', $patron->borrowernumber, 'flag toggled', 'INTRANET' ); + logaction( 'CIRCULATION', 'ISSUE', $patron->borrowernumber, 'item issued', 'INTRANET' ); + logaction( 'CATALOGUING', 'ADD', 42, 'biblio added', 'COMMANDLINE' ); + logaction( 'CATALOGUING', 'MODIFY', 42, 'biblio edited', 'COMMANDLINE' ); + + # Multi-value module via q + my $q_multi = encode_json( { module => [ 'MEMBERS', 'CIRCULATION' ] } ); + $t->get_ok("//$userid:$password@/api/v1/action_logs?q=$q_multi")->status_is(200); + is( scalar @{ $t->tx->res->json }, 2, 'multi-value module filter returns union' ); + + # Substring (LIKE) on info + my $q_like = encode_json( { info => { -like => '%biblio%' } } ); + $t->get_ok("//$userid:$password@/api/v1/action_logs?q=$q_like")->status_is(200); + is( scalar @{ $t->tx->res->json }, 2, 'like operator on info matches both biblio rows' ); + + # Object filter via typed param + $t->get_ok("//$userid:$password@/api/v1/action_logs?object=42")->status_is(200); + is( scalar @{ $t->tx->res->json }, 2, 'object filter returns rows for object 42' ); + + # Combined filter + my $q_combo = encode_json( + { + module => 'CATALOGUING', + action => 'MODIFY' + } + ); + $t->get_ok("//$userid:$password@/api/v1/action_logs?q=$q_combo")->status_is(200); + my $combo = $t->tx->res->json; + is( scalar @$combo, 1, 'combined filter narrows to one row' ); + is( $combo->[0]->{action}, 'MODIFY', 'combined filter returned MODIFY' ); + + $schema->storage->txn_rollback; +}; + +subtest 'embeds' => sub { + plan tests => 10; + $schema->storage->txn_begin; + + my ( $librarian, $patron, $password ) = build_fixtures(); + my $userid = $librarian->userid; + + logaction( 'MEMBERS', 'MODIFY', $patron->borrowernumber, 'embed test', 'INTRANET' ); + logaction( 'CATALOGUING', 'ADD', 42, 'biblio', 'COMMANDLINE' ); + + # Without embed, no librarian/patron objects in payload + $t->get_ok("//$userid:$password@/api/v1/action_logs?module=MEMBERS")->status_is(200); + my $bare = $t->tx->res->json->[0]; + ok( !exists $bare->{librarian}, 'no librarian key without embed' ); + ok( !exists $bare->{patron}, 'no patron key without embed' ); + + # With embed=librarian, librarian object populated + $t->get_ok( "/api/v1/action_logs?module=MEMBERS" => + { 'x-koha-embed' => 'librarian', Authorization => _basic( $userid, $password ) } )->status_is(200); + my $embed_lib = $t->tx->res->json->[0]; + is( + $embed_lib->{librarian}->{patron_id}, + $librarian->borrowernumber, + 'embedded librarian.patron_id matches the actor' + ); + + # With embed=patron, patron object populated for MEMBERS row + $t->get_ok( "/api/v1/action_logs?module=MEMBERS" => + { 'x-koha-embed' => 'patron', Authorization => _basic( $userid, $password ) } )->status_is(200); + my $embed_pat = $t->tx->res->json->[0]; + is( + $embed_pat->{patron}->{patron_id}, + $patron->borrowernumber, + 'embedded patron.patron_id matches the row object' + ); + + $schema->storage->txn_rollback; +}; + +subtest 'pagination and sorting' => sub { + plan tests => 6; + $schema->storage->txn_begin; + + my ( $librarian, $patron, $password ) = build_fixtures(); + my $userid = $librarian->userid; + + logaction( 'MEMBERS', 'MODIFY', $patron->borrowernumber, "row $_", 'INTRANET' ) for 1 .. 5; + + $t->get_ok("//$userid:$password@/api/v1/action_logs?_per_page=2&_page=1&module=MEMBERS")->status_is(200); + is( scalar @{ $t->tx->res->json }, 2, 'pagination limits results' ); + is( $t->tx->res->headers->header('x-total-count'), 5, 'X-Total-Count reflects unfiltered total' ); + + $t->get_ok("//$userid:$password@/api/v1/action_logs?_order_by=-action_id&module=MEMBERS")->status_is(200); + + $schema->storage->txn_rollback; +}; + +sub _basic { + my ( $u, $p ) = @_; + require MIME::Base64; + return 'Basic ' . MIME::Base64::encode_base64( "$u:$p", '' ); +}