1# BEGIN BPS TAGGED BLOCK {{{
2#
3# COPYRIGHT:
4#
5# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
6#                                          <sales@bestpractical.com>
7#
8# (Except where explicitly superseded by other copyright notices)
9#
10#
11# LICENSE:
12#
13# This work is made available to you under the terms of Version 2 of
14# the GNU General Public License. A copy of that license should have
15# been provided with this software, but in any event can be snarfed
16# from www.gnu.org.
17#
18# This work is distributed in the hope that it will be useful, but
19# WITHOUT ANY WARRANTY; without even the implied warranty of
20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21# General Public License for more details.
22#
23# You should have received a copy of the GNU General Public License
24# along with this program; if not, write to the Free Software
25# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26# 02110-1301 or visit their web page on the internet at
27# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28#
29#
30# CONTRIBUTION SUBMISSION POLICY:
31#
32# (The following paragraph is not intended to limit the rights granted
33# to you to modify and distribute this software under the terms of
34# the GNU General Public License and is only of importance to you if
35# you choose to contribute your changes and enhancements to the
36# community by submitting them to Best Practical Solutions, LLC.)
37#
38# By intentionally submitting any modifications, corrections or
39# derivatives to this work, or any other work intended for use with
40# Request Tracker, to Best Practical Solutions, LLC, you confirm that
41# you are the copyright holder for those contributions and you grant
42# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43# royalty-free, perpetual, license to use, copy, create derivative
44# works based on those contributions, and sublicense and distribute
45# those contributions and any derivatives thereof.
46#
47# END BPS TAGGED BLOCK }}}
48
49package RT::Test::Web;
50
51use strict;
52use warnings;
53
54use base qw(Test::WWW::Mechanize);
55use MIME::Base64 qw//;
56use Encode 'encode_utf8';
57use Storable 'thaw';
58use HTTP::Status qw();
59
60BEGIN { require RT::Test; }
61require Test::More;
62
63$RT::Test::Web::INSTANCES = undef;
64
65sub new {
66    my ($class, @args) = @_;
67
68    push @args, app => $RT::Test::TEST_APP if $RT::Test::TEST_APP;
69    my $self = $class->SUPER::new(@args);
70    $self->cookie_jar(HTTP::Cookies->new);
71    # Clear our caches of anything that the server process may have done
72    $self->add_handler(
73        response_done => sub {
74            RT::Record->FlushCache;
75        },
76    ) if RT::Record->can( "FlushCache" );
77
78    $RT::Test::Web::INSTANCES++;
79    return $self;
80}
81
82sub clone {
83    my $self = shift;
84    $RT::Test::Web::INSTANCES++ if defined $RT::Test::Web::INSTANCES;
85    return $self->SUPER::clone();
86}
87
88sub get_ok {
89    my $self = shift;
90    my $url = shift;
91    if ( $url =~ s!^/!! ) {
92        $url = $self->rt_base_url . $url;
93    }
94
95    local $Test::Builder::Level = $Test::Builder::Level + 1;
96    my $rv = $self->SUPER::get_ok($url, @_);
97    Test::More::diag( "Couldn't get $url" ) unless $rv;
98    return $rv;
99}
100
101sub rt_base_url {
102    return $RT::Test::existing_server if $RT::Test::existing_server;
103    return "http://localhost:" . RT->Config->Get('WebPort') . RT->Config->Get('WebPath') . "/";
104}
105
106sub login {
107    my $self = shift;
108    my $user = shift || 'root';
109    my $pass = shift || 'password';
110    my %args = @_;
111
112    $self->logout if $args{logout};
113
114    my $url = $self->rt_base_url;
115    $self->get($url . "?user=$user;pass=$pass");
116
117    return 0 unless $self->logged_in_as($user);
118
119    unless ( $self->content =~ m/Logout/i ) {
120        Test::More::diag("error: page has no Logout");
121        return 0;
122    }
123    return 1;
124}
125
126sub logged_in_as {
127    my $self = shift;
128    my $user = shift || '';
129
130    if ( $user =~ /\@/ ) {
131        my $user_object = RT::User->new( RT->SystemUser );
132        $user_object->LoadByEmail($user);
133        if ( $user_object->Id ) {
134            $user = $user_object->Name;
135        }
136    }
137
138    unless ( $self->status == HTTP::Status::HTTP_OK ) {
139        Test::More::diag( "error: status is ". $self->status );
140        return 0;
141    }
142    RT::Interface::Web::EscapeHTML(\$user);
143    unless ( $self->content =~ m{<span class="current-user">\Q$user\E</span>}i ) {
144        Test::More::diag("Page has no user name");
145        return 0;
146    }
147    return 1;
148}
149
150sub logout {
151    my $self = shift;
152
153    my $url = $self->rt_base_url;
154    $self->get($url);
155    Test::More::diag( "error: status is ". $self->status )
156        unless $self->status == HTTP::Status::HTTP_OK;
157
158    if ( $self->content =~ /Logout/i ) {
159        $self->follow_link( text => 'Logout' );
160        Test::More::diag( "error: status is ". $self->status ." when tried to logout" )
161            unless $self->status == HTTP::Status::HTTP_OK;
162    }
163    else {
164        return 1;
165    }
166
167    $self->get($url);
168    if ( $self->content =~ /Logout/i ) {
169        Test::More::diag( "error: couldn't logout" );
170        return 0;
171    }
172    return 1;
173}
174
175sub goto_ticket {
176    my $self = shift;
177    my $id   = shift;
178    my $view = shift || 'Display';
179    my $status = shift || HTTP::Status::HTTP_OK;
180    unless ( $id && int $id ) {
181        Test::More::diag( "error: wrong id ". defined $id? $id : '(undef)' );
182        return 0;
183    }
184
185    my $url = $self->rt_base_url;
186    $url .= "Ticket/${ view }.html?id=$id";
187    $self->get($url);
188    unless ( $self->status == $status ) {
189        Test::More::diag( "error: status is ". $self->status );
190        return 0;
191    }
192    return 1;
193}
194
195sub goto_create_ticket {
196    my $self = shift;
197    my $queue = shift;
198
199    my $id;
200    if ( ref $queue ) {
201        $id = $queue->id;
202    } elsif ( $queue =~ /^\d+$/ ) {
203        $id = $queue;
204    } else {
205        my $queue_obj = RT::Queue->new(RT->SystemUser);
206        my ($ok, $msg) = $queue_obj->Load($queue);
207        die "Unable to load queue '$queue': $msg" if !$ok;
208        $id = $queue_obj->id;
209    }
210
211    $self->get($self->rt_base_url . 'Ticket/Create.html?Queue='.$id);
212
213    return 1;
214}
215
216sub get_warnings {
217    my $self = shift;
218    local $Test::Builder::Level = $Test::Builder::Level + 1;
219
220    # We clone here so that when we fetch warnings, we don't disrupt the state
221    # of the test's mech. If we reuse the original mech then you can't
222    # test warnings immediately after fetching page XYZ, then fill out
223    # forms on XYZ. This is because the most recently fetched page has changed
224    # from XYZ to /__test_warnings, which has no form.
225    my $clone = $self->clone;
226
227    return unless $clone->get_ok('/__test_warnings');
228    return @{ thaw $clone->content };
229}
230
231sub warning_like {
232    my $self = shift;
233    my $re   = shift;
234    my $name = shift;
235
236    local $Test::Builder::Level = $Test::Builder::Level + 1;
237
238    my @warnings = $self->get_warnings;
239    if (@warnings == 0) {
240        Test::More::fail("no warnings emitted; expected 1");
241        return 0;
242    }
243    elsif (@warnings > 1) {
244        Test::More::fail(scalar(@warnings) . " warnings emitted; expected 1");
245        for (@warnings) {
246            Test::More::diag("got warning: $_");
247        }
248        return 0;
249    }
250
251    return Test::More::like($warnings[0], $re, $name);
252}
253
254sub next_warning_like {
255    my $self = shift;
256    my $re   = shift;
257    my $name = shift;
258
259    local $Test::Builder::Level = $Test::Builder::Level + 1;
260
261    if (@{ $self->{stashed_server_warnings} || [] } == 0) {
262        my @warnings = $self->get_warnings;
263        if (@warnings == 0) {
264            Test::More::fail("no warnings emitted; expected 1");
265            return 0;
266        }
267        $self->{stashed_server_warnings} = \@warnings;
268    }
269
270    my $warning = shift @{ $self->{stashed_server_warnings} };
271    return Test::More::like($warning, $re, $name);
272}
273
274sub no_warnings_ok {
275    my $self = shift;
276    my $name = shift || "no warnings emitted";
277
278    local $Test::Builder::Level = $Test::Builder::Level + 1;
279
280    my @warnings = $self->get_warnings;
281
282    Test::More::is(@warnings, 0, $name);
283    for (@warnings) {
284        Test::More::diag("got warning: $_");
285    }
286
287    return @warnings == 0 ? 1 : 0;
288}
289
290sub no_leftover_warnings_ok {
291    my $self = shift;
292
293    my $name = shift || "no leftover warnings";
294
295    local $Test::Builder::Level = $Test::Builder::Level + 1;
296
297    # we clear the warnings because we don't want to break later tests
298    # in case there *are* leftover warnings
299    my @warnings = splice @{ $self->{stashed_server_warnings} || [] };
300
301    Test::More::is(@warnings, 0, $name);
302    for (@warnings) {
303        Test::More::diag("leftover warning: $_");
304    }
305
306    return @warnings == 0 ? 1 : 0;
307}
308
309sub ticket_status {
310    my $self = shift;
311    my $id = shift;
312
313    $self->display_ticket( $id);
314    my ($got) = ($self->content =~ m{Status:\s*</div>\s*<div.*?>\s*<span.*?>\s*([\w ]+?)\s*</span>}ism);
315    unless ( $got ) {
316        Test::More::diag("Error: couldn't find status value on the page, may be regexp problem");
317    }
318    return $got;
319}
320
321sub ticket_status_is {
322    my $self = shift;
323    my $id = shift;
324    my $status = shift;
325    my $desc = shift || "Status of the ticket #$id is '$status'";
326    local $Test::Builder::Level = $Test::Builder::Level + 1;
327    return Test::More::is($self->ticket_status( $id), $status, $desc);
328}
329
330sub get_ticket_id {
331    my $self = shift;
332    my $content = $self->content;
333    my $id = 0;
334    if ($content =~ /.*Ticket (\d+) created.*/g) {
335        $id = $1;
336    }
337    elsif ($content =~ /.*No permission to view newly created ticket #(\d+).*/g) {
338        Test::More::diag("\nNo permissions to view the ticket.\n") if($ENV{'TEST_VERBOSE'});
339        $id = $1;
340    }
341    return $id;
342}
343
344sub set_custom_field {
345    my $self   = shift;
346    my $queue   = shift;
347    my $cf_name = shift;
348    my $val     = shift;
349
350    my $field_name = $self->custom_field_input( $queue, $cf_name )
351        or return 0;
352
353    $self->field($field_name, $val);
354    return 1;
355}
356
357sub custom_field_input {
358    my $self   = shift;
359    my $queue   = shift;
360    my $cf_name = shift;
361
362    my $cf_obj = RT::CustomField->new( $RT::SystemUser );
363    $cf_obj->LoadByName(
364        Name => $cf_name,
365        LookupType => RT::Ticket->CustomFieldLookupType,
366        ObjectId => $queue,
367    );
368    unless ( $cf_obj->id ) {
369        Test::More::diag("Can not load custom field '$cf_name' in queue '$queue'");
370        return undef;
371    }
372    my $cf_id = $cf_obj->id;
373
374    my ($res) =
375        grep /^Object-RT::Ticket-\d*-CustomField(?::\w+)?-$cf_id-Values?$/,
376        map $_->name,
377        $self->current_form->inputs;
378    unless ( $res ) {
379        Test::More::diag("Can not find input for custom field '$cf_name' #$cf_id");
380        return undef;
381    }
382    return $res;
383}
384
385sub value_name {
386    my $self = shift;
387    my $field = shift;
388
389    my $input = $self->current_form->find_input( $field )
390        or return undef;
391
392    my @names = $input->value_names;
393    return $input->value unless @names;
394
395    my @values = $input->possible_values;
396    for ( my $i = 0; $i < @values; $i++ ) {
397        return $names[ $i ] if $values[ $i ] eq $input->value;
398    }
399    return undef;
400}
401
402
403sub check_links {
404    my $self = shift;
405    my %args = @_;
406
407    my %has = map {$_ => 1} @{ $args{'has'} };
408    my %has_no = map {$_ => 1} @{ $args{'has_no'} };
409
410    local $Test::Builder::Level = $Test::Builder::Level + 1;
411
412    my @found;
413
414    my @links = $self->followable_links;
415    foreach my $text ( grep defined && length, map $_->text, @links ) {
416        push @found, $text if $has_no{ $text };
417        delete $has{ $text };
418    }
419    if ( @found || keys %has ) {
420        Test::More::ok( 0, "expected links" );
421        Test::More::diag( "didn't expect, but found: ". join ', ', map "'$_'", @found )
422            if @found;
423        Test::More::diag( "didn't find, but expected: ". join ', ', map "'$_'", keys %has )
424            if keys %has;
425        return 0;
426    }
427    return Test::More::ok( 1, "expected links" );
428}
429
430sub auth {
431    my $self = shift;
432    $self->default_header( $self->auth_header(@_) );
433}
434
435sub auth_header {
436    my $self = shift;
437    return Authorization => "Basic " .
438        MIME::Base64::encode( join(":", @_) );
439}
440
441sub dom {
442    my $self = shift;
443    Carp::croak("Can not get DOM, not HTML repsone")
444        unless $self->is_html;
445    require Mojo::DOM;
446    return Mojo::DOM->new( $self->content );
447}
448
449# override content_* and text_* methods in Test::Mech to dump the content
450# on failure, to speed investigation
451for my $method_name (qw/
452    content_is content_contains content_lacks content_like content_unlike
453    text_contains text_lacks text_like text_unlike
454/) {
455    my $super_method = __PACKAGE__->SUPER::can($method_name);
456    my $implementation = sub {
457        local $Test::Builder::Level = $Test::Builder::Level + 1;
458
459        my $self = shift;
460        my $ok = $self->$super_method(@_);
461        if (!$ok) {
462            my $dir = RT::Test->temp_directory;
463            my ($name) = $self->uri->path =~ m{/([^/]+)$};
464            $name ||= 'index.html';
465
466            my $file = $dir . '/' . RT::Test->builder->current_test . '-' . $name;
467
468            open my $handle, '>', $file or die $!;
469            print $handle encode_utf8($self->content) or die $!;
470            close $handle or die $!;
471
472            Test::More::diag("Dumped failing test page content to $file");
473        }
474        return $ok;
475    };
476
477    no strict 'refs';
478    *{$method_name} = $implementation;
479}
480
481sub DESTROY {
482    my $self = shift;
483
484    if (defined $RT::Test::Web::INSTANCES) {
485        $RT::Test::Web::INSTANCES--;
486        if ($RT::Test::Web::INSTANCES == 0 ) {
487            # Ordering matters -- clean out INSTANCES before we check
488            # warnings, so the clone therein sees that we've already begun
489            # cleanups.
490            undef $RT::Test::Web::INSTANCES;
491            $self->no_warnings_ok;
492        }
493    }
494}
495
496END {
497    return if RT::Test->builder->{Original_Pid} != $$;
498    if (defined $RT::Test::Web::INSTANCES and $RT::Test::Web::INSTANCES == 0 ) {
499        # Ordering matters -- clean out INSTANCES after the `new`
500        # bumps it up to 1.
501        my $cleanup = RT::Test::Web->new;
502        undef $RT::Test::Web::INSTANCES;
503        $cleanup->no_warnings_ok;
504    }
505}
506
5071;
508