From: Edmund von der Burg Date: Wed, 20 May 2009 15:40:11 +0000 (+0000) Subject: Added high performance version of the Sessions code as a branch X-Git-Url: http://git.shadowcat.co.uk/gitweb/gitweb.cgi?a=commitdiff_plain;h=refs%2Fheads%2Fhigh_performance;p=catagits%2FCatalyst-Plugin-Session.git Added high performance version of the Sessions code as a branch --- 95c6f6e86161fe6d66139bc83b3f168d4729d3e2 diff --git a/Changes b/Changes new file mode 100755 index 0000000..25ddb7d --- /dev/null +++ b/Changes @@ -0,0 +1,11 @@ +0.01: + * initial (internal) release + +0.02: + * check '$c->session->{__limit_session_to_this_visit}' before setting an + expiration time for the cookie. + +0.03: + * don't send No-Cache header over https - shouldn't be needed and break + document downloads on IE due to IE bug. + diff --git a/MANIFEST b/MANIFEST new file mode 100755 index 0000000..d6b28c3 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,21 @@ +Changes +lib/Catalyst/Plugin/SessionHP.pm +lib/Catalyst/Plugin/SessionHP/State.pm +lib/Catalyst/Plugin/SessionHP/State/Cookie.pm +Makefile.PL +MANIFEST +README +requirements.txt +t/00_basic_sanity.t +t/01use.t +t/01_setup.t +t/03_flash.t +t/05_semi_persistent_flash.t +t/99_pod.t +t/99_podcoverage.t +t/basic.t +t/lib/FlashTestApp.pm +t/lib/SessionTestApp.pm +t/live_app_cookie.t +t/live_app_session.t +t/live_simple_session.t diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP new file mode 100755 index 0000000..8dbd2a5 --- /dev/null +++ b/MANIFEST.SKIP @@ -0,0 +1,38 @@ +# Avoid version control files. +\bRCS\b +\bCVS\b +,v$ +\B\.svn\b + +# Avoid Makemaker generated and utility files. +\bMakefile$ +\bblib +\bMakeMaker-\d +\bpm_to_blib$ +\bblibdirs$ +^MANIFEST\.SKIP$ + +# Avoid Module::Build generated and utility files. +\bBuild$ +\b_build + +# Avoid temp and backup files. +~$ +\.tmp$ +\.old$ +\.bak$ +\#$ +\b\.# +^..*\.sw[po]$ + +# prereq tests may fail due to optionals +99_prereq\.t$ + +# Module::Bane +\bBuild.PL$ + +# Shipit conf +.shipit + +.DS_Store +Catalyst-Plugin-SessionHP diff --git a/Makefile.PL b/Makefile.PL new file mode 100755 index 0000000..9d03f58 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,32 @@ +use strict; +use ExtUtils::MakeMaker; + +use strict; +use warnings; + +WriteMakefile( + NAME => 'Catalyst-Plugin-SessionHP', + VERSION_FROM => 'lib/Catalyst/Plugin/SessionHP.pm', + PREREQ_PM => { + + 'Catalyst::Runtime' => '5.7010', + 'Catalyst::Plugin::Authentication' => '0.10011', + + 'Digest::SHA1' => 0, + 'File::Spec' => 0, + 'File::Temp' => 0, + 'Object::Signature' => 0, + 'MRO::Compat' => 0, + 'Clone' => 0, + 'Carp' => 0, + + # an indirect dep. needs a certain version. + 'Tie::RefHash' => '1.34', + + 'Test::More' => 0, + 'Test::Deep' => 0, + 'Test::Exception' => 0, + 'Test::MockObject' => '1.01', + }, +); + diff --git a/README b/README new file mode 100755 index 0000000..e69de29 diff --git a/lib/Catalyst/Plugin/SessionHP.pm b/lib/Catalyst/Plugin/SessionHP.pm new file mode 100755 index 0000000..4c9548b --- /dev/null +++ b/lib/Catalyst/Plugin/SessionHP.pm @@ -0,0 +1,503 @@ +#!/usr/bin/perl + +package Catalyst::Plugin::SessionHP; +use base qw/Class::Accessor::Fast /; + +use strict; +use warnings; + +use MRO::Compat; +use Catalyst::Exception (); +use Digest::SHA1 qw(sha1_hex); +use overload (); +use Object::Signature (); +use Carp; +use Clone; + +use Data::Dumper; +local $Data::Dumper::Sortkeys = 1; + +our $VERSION = '0.03'; + +my @session_data_accessors; # used in delete_session + +BEGIN { + __PACKAGE__->mk_accessors( + "_session_delete_reason", + @session_data_accessors = ( + + '_session', + '_session_source', # where did the current session come from + '_session_stored_data_signature', # what is currently in the store + '_session_id', # the current session id + '_session_expiry_time', # when the current session should expire + + '_flash', # the current flash hashref + '_original_flash', # the original flash hashref (cloned) + + ) + ); +} + +sub setup { + my $c = shift; + $c->maybe::next::method(@_); + $c->check_session_plugin_requirements; + $c->setup_session; + return $c; +} + +sub check_session_plugin_requirements { + my $c = shift; + + unless ( $c->isa("Catalyst::Plugin::SessionHP::State") + && $c->isa("Catalyst::Plugin::Session::Store") ) + { + my $err = ( "The Session plugin requires both Session::State " + . "and Session::Store plugins to be used as well." ); + + $c->log->fatal($err); + Catalyst::Exception->throw($err); + } +} + +sub setup_session { + my $c = shift; + my $hour = 60 * 60; + + my $cfg = ( $c->config->{session} ||= {} ); + + %$cfg = ( + max_lifetime => $hour * 2, + min_lifetime => $hour * 1, + + %$cfg, + ); + + $c->maybe::next::method(); +} + +########################################################################### + +sub finalize_headers { + my $c = shift; + $c->finalize_session; + return $c->maybe::next::method(@_); +} + +sub finalize_body { + my $c = shift; + + # Have to call this now - it has the side effect of actually causing the + # session data to be written to the database in + # Catalyst::Plugin::Session::Store::Delegate + $c->_clear_session_instance_data; + + return $c->maybe::next::method(@_); +} + +############################################################################# + +sub session { + my $c = shift; + + return $c->_session + || $c->_load_session # + || $c->_create_new_session; # +} + +sub session_expires { + my $c = shift; + return $c->_session_expiry_time || 0; +} + +sub finalize_session { + my $c = shift; + $c->_save_flash_to_session; + $c->_save_session; + $c->maybe::next::method(@_); +} + +sub _create_new_session { + my $c = shift; + + # get new settings + my $id = $c->generate_session_id; + my $expiry_time = time() + $c->config->{session}{max_lifetime}; + + # create a new session + $c->_session_source('new'); + $c->_session_id($id); + $c->_session_expiry_time($expiry_time); + $c->_session_stored_data_signature(''); + $c->_session( {} ); + + return $c->_session(); +} + +my $session_hash_seed_counter = 0; + +sub generate_session_id { + my $c = shift; + + # create a string that will be hard to guess + my $session_hash_seed = join "", + $session_hash_seed_counter++, + time, rand, $$, {}, overload::StrVal($c); + + # turn the random string into a hex string + my $new_id = sha1_hex($session_hash_seed); + + return $new_id; +} + +sub validate_session_id { + my ( $c, $sid ) = @_; + + return $sid + && $sid =~ m{ \A [a-f0-9]{40} \z }x; # match SHA1 hexdigest +} + +sub _save_session { + my $c = shift; + + # Get the session data + my $session_data = $c->_session; + + # if there is no session data then there is nothing to store + return unless $session_data; + + # Check that the session either exists or has contents. + if ($c->_session_source ne 'new' # already in store + || %$session_data # contains something + ) + { + + my $sid = $c->session_id; + my $cfg = $c->config->{session}; + + # check to see if the session has changed at all + if ( Object::Signature::signature($session_data) ne + $c->_session_stored_data_signature ) + { + $session_data->{__created} ||= time(); + $session_data->{__updated} = time(); + $c->store_session_data( "session:$sid" => $session_data ); + } + + # check to see if the expiry should be extended + my $current_expiry_time = $c->_session_expiry_time; + my $current_lifetime = $current_expiry_time - time(); + my $new_expiry_time # + = $current_lifetime < $cfg->{min_lifetime} + ? time() + $cfg->{max_lifetime} + : $current_expiry_time; + + # save the expiry if it is a new session or time has changed + if ( $current_expiry_time != $new_expiry_time + || $c->_session_source eq 'new' ) + { + $c->store_session_data( "expires:$sid" => $new_expiry_time ); + $c->_session_expiry_time($new_expiry_time); + } + + } else { + + # there was no session worth saving - clear it + $c->_clear_session_instance_data; + } +} + +sub _clear_session_instance_data { + my $c = shift; + $c->maybe::next::method(@_); # allow other plugins to hook in on this + $c->$_(undef) for @session_data_accessors; +} + +sub _load_session { + my $c = shift; + + # try to retrieve a session_id from the state + my $id = $c->session_id # + || return; + + # check that the id is valid + if ( !$c->validate_session_id($id) ) { + $c->delete_session('invalid session key'); + return; + } + + # get the expiry time and session data + my $expiry_time = $c->get_session_data("expires:$id") || 0; + my $session_data = $c->get_session_data("session:$id") || undef; + + # check that the session is good (has data and has not expired) + if ( $session_data && $expiry_time > time() ) { + + # store all the bits retrieved + $c->_session_source('store'); + $c->_session_id($id); + $c->_session_expiry_time($expiry_time); + $c->_session($session_data); + $c->_session_stored_data_signature( + Object::Signature::signature($session_data) ); + + $c->log->debug(qq/Restored session "$id"/) if $c->debug; + + } else { + + # we set the session_id so that it is available to the state and store. + $c->_session_id($id); + + # call delete session so that the state and store can clean up. + $c->delete_session('session expired'); + } + + return $session_data; +} + +sub delete_session { + my ( $c, $msg ) = @_; + + $c->session_delete_reason($msg); + + # let others delete first + $c->maybe::next::method($msg); + + $c->log->debug( "Deleting session" + . ( defined($msg) ? "($msg)" : '(no reason given)' ) ) + if $c->debug; + + # delete the session data + if ( my $sid = $c->session_id ) { + $c->delete_session_data("${_}:${sid}") for qw/session expires flash/; + } + + # reset the values in the context object + # see the BEGIN block + $c->_clear_session_instance_data; +} + +sub session_delete_reason { + my $c = shift; + $c->_session_delete_reason(@_); +} + +# sub session_expires { +# my $c = shift; +# +# if ( defined( my $expires = $c->_extended_session_expires ) ) { +# return $expires; +# } elsif ( defined( $expires = $c->_load_session_expires ) ) { +# return $c->extend_session_expires($expires); +# } else { +# return 0; +# } +# } +# +# sub extend_session_expires { +# my ( $c, $expires ) = @_; +# $c->_extended_session_expires( my $updated +# = $c->calculate_extended_session_expires($expires) ); +# $c->extend_session_id( $c->session_id, $updated ); +# return $updated; +# } +# +# sub calculate_initial_session_expires { +# my $c = shift; +# return ( time() + $c->config->{session}{expires} ); +# } +# +# sub calculate_extended_session_expires { +# my ( $c, $prev ) = @_; +# $c->calculate_initial_session_expires; +# } +# +# sub reset_session_expires { +# my ( $c, $sid ) = @_; +# +# my $exp = $c->calculate_initial_session_expires; +# $c->_session_expires($exp); +# $c->_extended_session_expires($exp); +# $exp; +# } + +sub session_id { + my $c = shift; + + return + $c->_session_id + || $c->_session_id( $c->get_sesson_id_from_state ) + || ''; +} + +# sub _load_session_id { +# my $c = shift; +# return if $c->_tried_loading_session_id; +# $c->_tried_loading_session_id(1); +# +# if ( defined( my $sid = $c->get_session_id ) ) { +# if ( $c->validate_session_id($sid) ) { +# +# # temporarily set the inner key, so that validation will work +# $c->_session_id($sid); +# return $sid; +# } else { +# my $err = "Tried to set invalid session ID '$sid'"; +# $c->log->error($err); +# Catalyst::Exception->throw($err); +# } +# } +# +# return; +# } +# +# sub session_is_valid { +# my $c = shift; +# +# # force a check for expiry, but also __address, etc +# if ( $c->_load_session ) { +# return 1; +# } else { +# return; +# } +# } +# +# sub validate_session_id { +# my ( $c, $sid ) = @_; +# +# $sid and $sid =~ /^[a-f\d]+$/i; +# } +# +# +# +# +# sub dump_these { +# my $c = shift; +# +# ( $c->maybe::next::method(), +# +# $c->session_id +# ? ( [ "Session ID" => $c->session_id ], +# [ Session => $c->session ], +# ) +# : () +# ); +# } +# +# sub get_session_id { shift->maybe::next::method(@_) } +# sub set_session_id { shift->maybe::next::method(@_) } +# sub delete_session_id { shift->maybe::next::method(@_) } +# sub extend_session_id { shift->maybe::next::method(@_) } + +# Flash related subs + +sub _save_flash_to_session { + my $c = shift; + + my $current_flash = $c->_flash # + || return; + + my $original_flash = $c->_original_flash || {}; + + # check that each existing key is different to the original one + foreach my $key ( keys %$current_flash ) { + + # next if there was no entry before + next if !exists $original_flash->{$key}; + + # get a signature of both + my $current_sig + = Object::Signature::signature( \$current_flash->{$key} ); + my $original_sig + = Object::Signature::signature( \$original_flash->{$key} ); + + # if sigs are the same delete + delete $current_flash->{$key} + if $current_sig eq $original_sig; + + } + + if (%$current_flash) { + my $session_data = $c->session; + $session_data->{__flash} = $current_flash; + } else { + my $session_data = $c->_session; + delete $session_data->{__flash} if $session_data; + } + + # clear the flash so that we reload from session if needed + $c->_flash(undef); + $c->_original_flash(undef); + + return 1; +} + +sub flash { + my $c = shift; + + return + $c->_flash + || $c->_load_flash + || $c->_create_new_flash; +} + +sub _load_flash { + my $c = shift; + my $flash = $c->session->{__flash}; + + return unless $flash; + + $c->_original_flash( Clone::clone $flash); + $c->_flash($flash); +} + +sub _create_new_flash { + my $c = shift; + + $c->_original_flash( {} ); + $c->_flash( {} ); + + return $c->_flash; +} + +sub keep_flash { + my ( $c, @keys ) = @_; + my $original = $c->_original_flash; + + # deleting from the original flash will cause current values to be kept + delete $original->{$_} for @keys; + + return 1; +} + +sub clear_flash { + my $c = shift; + $c->_flash( {} ); +} + +################################################################### +# compatability shims + +sub create_session_id_if_needed { + return 1; + + # my $c = shift; + # if ( my $id = $c->session_id ) { + # return $id; + # } + # + # $c->_create_new_session; + # return $c->session_id; +} + +sub sessionid { + my $c = shift; + return $c->session_id; +} + +sub session_is_valid { + return 1; +} + +1; diff --git a/lib/Catalyst/Plugin/SessionHP/State.pm b/lib/Catalyst/Plugin/SessionHP/State.pm new file mode 100755 index 0000000..532e5e6 --- /dev/null +++ b/lib/Catalyst/Plugin/SessionHP/State.pm @@ -0,0 +1,59 @@ +#!/usr/bin/perl + +package Catalyst::Plugin::SessionHP::State; + +use strict; +use warnings; + +__PACKAGE__; + +__END__ + +=pod + +=head1 NAME + +Catalyst::Plugin::SessionHP::State - Base class for session state +preservation plugins. + +=head1 SYNOPSIS + + package Catalyst::Plugin::SessionHP::State::MyBackend; + use base qw/Catalyst::Plugin::SessionHP::State/; + +=head1 DESCRIPTION + +This class doesn't actually provide any functionality, but when the +C module sets up it will check to see that +C<< YourApp->isa("Catalyst::Plugin::SessionHP::State") >>. + +When you write a session state plugin you should subclass this module this +reason only. + +=head1 WRITING STATE PLUGINS + +To write a session state plugin you usually need to extend two methods: + +=over 4 + +=item prepare_(action|cookies|whatever) + +Set C (accessor) at B time using data in the request. + +Note that this must happen B other C instances, in +order to get along with L. Overriding +C is probably the stablest approach. + +=item finalize + +Modify the response at to include the session ID if C is defined, +using whatever scheme you use. For example, set a cookie, + +=back + +=cut + + + + + diff --git a/lib/Catalyst/Plugin/SessionHP/State/Cookie.pm b/lib/Catalyst/Plugin/SessionHP/State/Cookie.pm new file mode 100755 index 0000000..17e9080 --- /dev/null +++ b/lib/Catalyst/Plugin/SessionHP/State/Cookie.pm @@ -0,0 +1,92 @@ +package Catalyst::Plugin::SessionHP::State::Cookie; +use base qw/Catalyst::Plugin::SessionHP::State Class::Accessor::Fast/; + +use strict; +use warnings; + +use MRO::Compat; +use Catalyst::Utils (); + +our $VERSION = "0.10"; + +BEGIN { __PACKAGE__->mk_accessors(qw/_deleted_session_id/) } + +sub setup_session { + my $c = shift; + + $c->maybe::next::method(@_); + + $c->config->{session}{cookie_name} + ||= Catalyst::Utils::appprefix($c) . '_session'; + +} + +sub _session_cookie_name { + my $c = shift; + return $c->config->{session}{cookie_name}; +} + +sub finalize_session { + my $c = shift; + + # we want to run after the other finalizing has been done + $c->maybe::next::method(@_); + + # If there is no session_id then we should not do anything + return unless $c->_session_id; + + # create the cookie + my $cookie = { value => $c->_session_id, }; + + # set the expriation time + # get the cookie expiry time and add a little buffer for testing + unless ( $c->session->{__session_limit_to_this_visit} ) { + $cookie->{expires} = $c->_session_expiry_time + 60; + } + + $cookie->{secure} = 1 if $c->config->{session}{cookie_secure}; + + # add the cookie to the headers + $c->response->cookies->{ $c->_session_cookie_name } = $cookie; + + # Also ensure that at the least the cookie is not cached. Other caching is + # upto the app to implement. Don't apply to secure connections as it leads + # to a bug where IE will not download files. + # (http://support.microsoft.com/kb/812935/en-us) + $c->response->header( 'Cache-control' => 'no-cache="set-cookie"' ) + unless $c->req->secure; +} + +sub get_sesson_id_from_state { + my $c = shift; + + # get _request_ cookie + my $cookie = $c->request->cookies->{ $c->_session_cookie_name }; + + if ($cookie) { + my $sid = $cookie->value; + $c->log->debug(qq/Found sessionid "$sid" in cookie/) if $c->debug; + return $sid if $sid; + } + + # If we could not find the id pass on to the next state + $c->maybe::next::method(@_); +} + +sub delete_session { + my ( $c, $msg ) = @_; + + # create the cookie + my $cookie = { + value => '', + expires => 0, + }; + $cookie->{secure} = 1 if $c->config->{session}{cookie_secure}; + + # add the cookie to the headers + $c->response->cookies->{ $c->_session_cookie_name } = $cookie; + + $c->maybe::next::method($msg); +} + +1; diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..a8fc737 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ + + +__REQUIREMENTS__ + + + * do not expend any server side resources for empty sessions + + * don't create a session cookie unless there is a session + + * be reluctant to write to the database (updating expiry etc) + + * allow us to distinguish between secure sessions and insecure ones. + + * allow us to store flash in memcache only \ No newline at end of file diff --git a/t/00_basic_sanity.t b/t/00_basic_sanity.t new file mode 100755 index 0000000..23e6013 --- /dev/null +++ b/t/00_basic_sanity.t @@ -0,0 +1,11 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More tests => 4; + + +my $m; BEGIN { use_ok($m = "Catalyst::Plugin::SessionHP") } + +can_ok($m, $_) for qw/session_id session session_delete_reason/; diff --git a/t/01_setup.t b/t/01_setup.t new file mode 100755 index 0000000..de379eb --- /dev/null +++ b/t/01_setup.t @@ -0,0 +1,74 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More tests => 10; +use Test::MockObject; +use Test::Deep; + +my $m; +BEGIN { use_ok( $m = "Catalyst::Plugin::SessionHP" ) } + +my %config; +my $log = Test::MockObject->new; +my @mock_isa = (); + +$log->set_true("fatal"); + +{ + + package MockCxt; + use MRO::Compat; + use base $m; + sub new { bless {}, $_[0] } + sub config { \%config } + sub log { $log } + + sub isa { + my $self = shift; + my $class = shift; + grep { $_ eq $class } @mock_isa or $self->SUPER::isa($class); + } +} + +can_ok( $m, "setup" ); + +eval { MockCxt->new->setup }; # throws OK is not working with NEXT +like( + $@, + qr/requires.*((?:State|Store).*){2}/i, + "can't setup an object that doesn't use state/store plugins" +); + +$log->called_ok( "fatal", "fatal error logged" ); + +@mock_isa = qw/Catalyst::Plugin::SessionHP::State/; +eval { MockCxt->new->setup }; +like( $@, qr/requires.*(?:Store)/i, + "can't setup an object that doesn't use state/store plugins" ); + +@mock_isa = qw/Catalyst::Plugin::Session::Store/; +eval { MockCxt->new->setup }; +like( $@, qr/requires.*(?:State)/i, + "can't setup an object that doesn't use state/store plugins" ); + +$log->clear; + +@mock_isa = + qw/Catalyst::Plugin::SessionHP::State Catalyst::Plugin::Session::Store/; +eval { MockCxt->new->setup }; +ok( !$@, "setup() lives with state/store plugins in use" ); +ok( !$log->called("fatal"), "no fatal error logged either" ); + +cmp_deeply( + [ keys %{ $config{session} } ], + bag(qw/min_lifetime max_lifetime/), + "default values for config were populated in successful setup", +); + +%config = ( session => { expires => 1234 } ); +MockCxt->new->setup; +is( $config{session}{expires}, + 1234, "user values are not overwritten in config" ); + diff --git a/t/01use.t b/t/01use.t new file mode 100755 index 0000000..2fe67d6 --- /dev/null +++ b/t/01use.t @@ -0,0 +1,4 @@ +use strict; +use Test::More tests => 1; + +BEGIN { use_ok('Catalyst::Plugin::SessionHP::State::Cookie') } diff --git a/t/03_flash.t b/t/03_flash.t new file mode 100755 index 0000000..16cdc99 --- /dev/null +++ b/t/03_flash.t @@ -0,0 +1,84 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More tests => 12; +use Test::MockObject::Extends; +use Test::Exception; +use Test::Deep; + +my $m; +BEGIN { use_ok( $m = "Catalyst::Plugin::SessionHP" ) } + +my $c = Test::MockObject::Extends->new($m); + +my $flash = {}; +$c->mock( + get_session_data => sub { + my ( $c, $key ) = @_; + return $key =~ /expire/ ? time() + 1000 : $flash; + }, +); +$c->mock( "debug" => sub {0} ); +$c->mock( "store_session_data" => sub { $flash = $_[2] } ); +$c->mock( "delete_session_data" => sub { $flash = {} } ); +$c->set_always( _session_id => "deadbeef" ); +$c->set_always( + config => { session => { max_lifetime => 1000, min_lifetime => 500 } } ); +$c->set_always( stash => {} ); + +# check that start state is as expected +is_deeply( $c->session, {}, "nothing in session" ); +is_deeply( $c->flash, {}, "nothing in flash" ); + +# set a value in the flash and check it gets to the flash +pass "--- add one value to the flash ---"; +$c->flash->{foo} = "moose"; +is_deeply( $c->flash, { foo => "moose" }, "one key in flash" ); +$c->finalize_headers; + + +cmp_deeply( + $c->session, + { __updated => re('^\d+$'), + __created => re('^\d+$'), + __flash => { foo => "moose" }, + }, + "session has __flash with flash data" +); + +pass "--- add second value to flash ---"; +$c->flash->{bar} = "gorch"; +is_deeply( + $c->flash, + { foo => "moose", bar => "gorch" }, + "two keys in flash" +); + +$c->finalize_headers; + +is_deeply( $c->flash, { bar => "gorch" }, "one key in flash" ); + +$c->finalize_headers; + +$c->flash->{test} = 'clear_flash'; + +$c->finalize_headers; + +$c->clear_flash(); + +is_deeply( $c->flash, {}, "nothing in flash after clear_flash" ); + +$c->finalize_headers; + +is_deeply( $c->flash, {}, + "nothing in flash after finalize after clear_flash" ); + +cmp_deeply( + $c->session, + { __updated => re('^\d+$'), __created => re('^\d+$'), }, + "session has empty __flash after clear_flash + finalize" +); + +$c->flash->{bar} = "gorch"; diff --git a/t/05_semi_persistent_flash.t b/t/05_semi_persistent_flash.t new file mode 100755 index 0000000..5331c69 --- /dev/null +++ b/t/05_semi_persistent_flash.t @@ -0,0 +1,40 @@ +#!/usr/bin/perl -w +use strict; + +use Test::More; + +BEGIN { + + eval { require Test::WWW::Mechanize::Catalyst } + or plan skip_all => + 'Test::WWW::Mechanize::Catalyst is required for this test'; + + plan tests => '10'; + +} + +use lib "t/lib"; +use Test::WWW::Mechanize::Catalyst 'FlashTestApp'; + +my $ua = Test::WWW::Mechanize::Catalyst->new; + +# flash absent for initial request +$ua->get_ok("http://localhost/first"); +$ua->content_contains( "flash is not set", "not set" ); + +# present for 1st req. +$ua->get_ok("http://localhost/second"); +$ua->content_contains( "flash set first time", "set first" ); + +# should be the same 2nd req. +$ua->get_ok("http://localhost/third"); +$ua->content_contains( "flash set second time", "set second" ); + +# and the third request, flash->{is_set} has the same value as 2nd. +$ua->get_ok("http://localhost/fourth"); +$ua->content_contains( "flash set 3rd time, same val as prev.", "set third" ); + +# and should be absent again for the 4th req. +$ua->get_ok("http://localhost/fifth"); +$ua->content_contains( "flash is not", "flash has gone" ); + diff --git a/t/99_pod.t b/t/99_pod.t new file mode 100755 index 0000000..1647794 --- /dev/null +++ b/t/99_pod.t @@ -0,0 +1,7 @@ +use Test::More; + +eval "use Test::Pod 1.14"; +plan skip_all => 'Test::Pod 1.14 required' if $@; +plan skip_all => 'set TEST_POD to enable this test' unless $ENV{TEST_POD}; + +all_pod_files_ok(); diff --git a/t/99_podcoverage.t b/t/99_podcoverage.t new file mode 100755 index 0000000..d91be5e --- /dev/null +++ b/t/99_podcoverage.t @@ -0,0 +1,7 @@ +use Test::More; + +eval "use Test::Pod::Coverage 1.04"; +plan skip_all => 'Test::Pod::Coverage 1.04 required' if $@; +plan skip_all => 'set TEST_POD to enable this test' unless $ENV{TEST_POD}; + +all_pod_coverage_ok(); diff --git a/t/basic.t b/t/basic.t new file mode 100755 index 0000000..3d40f0a --- /dev/null +++ b/t/basic.t @@ -0,0 +1,64 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More tests => 6; +use Test::MockObject; +use Test::MockObject::Extends; + +my $m; +BEGIN { use_ok( $m = "Catalyst::Plugin::SessionHP::State::Cookie" ) } + +my $cookie = Test::MockObject->new; +$cookie->set_always( value => "the session id" ); + +my $req = Test::MockObject->new; +my %req_cookies; +$req->set_always( cookies => \%req_cookies ); + +my $res = Test::MockObject->new; +my %res_cookies; +$res->set_always( cookies => \%res_cookies ); + +my $cxt = + Test::MockObject::Extends->new("Catalyst::Plugin::SessionHP::State::Cookie"); + +$cxt->set_always( config => {} ); +$cxt->set_always( request => $req ); +$cxt->set_always( response => $res ); +$cxt->set_always( session => { } ); +$cxt->set_always( session_expires => 123 ); +$cxt->set_false("debug"); +my $session_id; +$cxt->mock( session_id => sub { shift; $session_id = shift if @_; $session_id } ); + +can_ok( $m, "setup_session" ); + +$cxt->setup_session; + +like( $cxt->config->{session}{cookie_name}, + qr/_session$/, "default cookie name is set" ); + +$cxt->config->{session}{cookie_name} = "session"; + +can_ok( $m, "get_sesson_id_from_state" ); + +ok( !$cxt->get_sesson_id_from_state, "no session id yet"); + +$cxt->clear; + +%req_cookies = ( session => $cookie ); + +is( $cxt->get_sesson_id_from_state, "the session id", "session ID was restored from cookie" ); + +$cxt->clear; +$res->clear; + + +# can_ok( $m, "cookie_is_rejecting" ); +# %req_cookies = ( path => '/foo' ); +# $req->set_always( path => '' ); +# ok( $cxt->cookie_is_rejecting(\%req_cookies), "cookie is rejecting" ); +# $req->set_always( path => 'foo/bar' ); +# ok( !$cxt->cookie_is_rejecting(\%req_cookies), "cookie is not rejecting" ); diff --git a/t/lib/FlashTestApp.pm b/t/lib/FlashTestApp.pm new file mode 100755 index 0000000..728da06 --- /dev/null +++ b/t/lib/FlashTestApp.pm @@ -0,0 +1,61 @@ +#!/usr/bin/perl -w + +package FlashTestApp; +use Catalyst qw/SessionHP Session::Store::Dummy SessionHP::State::Cookie/; + +use strict; +use warnings; +no warnings 'uninitialized'; + +sub default : Private { + my ($self, $c) = @_; + $c->session; +} + + +sub first : Global { + my ( $self, $c ) = @_; + if ( ! $c->flash->{is_set}) { + $c->stash->{message} = "flash is not set"; + $c->flash->{is_set} = 1; + } +} + +sub second : Global { + my ( $self, $c ) = @_; + if ($c->flash->{is_set} == 1){ + $c->stash->{message} = "flash set first time"; + $c->flash->{is_set}++; + } +} + +sub third : Global { + my ( $self, $c ) = @_; + if ($c->flash->{is_set} == 2) { + $c->stash->{message} = "flash set second time"; + $c->keep_flash("is_set"); + } +} + +sub fourth : Global { + my ( $self, $c ) = @_; + if ($c->flash->{is_set} == 2) { + $c->stash->{message} = "flash set 3rd time, same val as prev." + } +} + +sub fifth : Global { + my ( $self, $c ) = @_; + $c->forward('/first'); +} + +sub end : Private { + my ($self, $c) = @_; + $c->res->output($c->stash->{message}); +} + + +__PACKAGE__->setup; + +__PACKAGE__; + diff --git a/t/lib/SessionTestApp.pm b/t/lib/SessionTestApp.pm new file mode 100755 index 0000000..efc3a00 --- /dev/null +++ b/t/lib/SessionTestApp.pm @@ -0,0 +1,61 @@ +#!/usr/bin/perl + +package SessionTestApp; +use Catalyst ( # + 'SessionHP', # + 'Session::Store::Dummy', # + 'SessionHP::State::Cookie' +); + +use strict; +use warnings; + +my $max_lifetime = 6; +my $min_lifetime = 3; + +__PACKAGE__->config( + session => { + max_lifetime => $max_lifetime, + min_lifetime => $min_lifetime, + } +); + +sub login : Global { + my ( $self, $c ) = @_; + $c->session->{logged_in} = 1; + $c->res->output("logged in"); +} + +sub logout : Global { + my ( $self, $c ) = @_; + $c->res->output( + "logged out after " . $c->session->{counter} . " requests" ); + $c->delete_session("logout"); +} + +sub page : Global { + my ( $self, $c ) = @_; + if ( $c->session->{logged_in} ) { + $c->res->output( + "you are logged in, session expires at " . $c->session_expires ); + $c->session->{counter}++; + } else { + $c->res->output("please login"); + } +} + +# This action inspects the session which will cause it to be auto_vivified into +# a hash. However we should not create a session because of this. +sub inspect_session : Global { + my ( $self, $c ) = @_; + + my $logged_in = $c->session->{logged_in}; + $logged_in = 'undef' if !defined $logged_in; + + $c->res->output("value of logged_in is '$logged_in'"); +} + +__PACKAGE__->setup; + +__PACKAGE__; + diff --git a/t/live_app_cookie.t b/t/live_app_cookie.t new file mode 100755 index 0000000..633a36f --- /dev/null +++ b/t/live_app_cookie.t @@ -0,0 +1,87 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More; + +BEGIN { + eval { require Test::WWW::Mechanize::Catalyst }; + plan skip_all => + "This test requires Test::WWW::Mechanize::Catalyst in order to run" + if $@; + plan skip_all => 'Test::WWW::Mechanize::Catalyst >= 0.40 required' if $Test::WWW::Mechanize::Catalyst::VERSION < 0.40; + plan 'no_plan'; +} + +{ + + package CookieTestApp; + use Catalyst qw/ + Session + Session::Store::Dummy + Session::State::Cookie + /; + + sub page : Local { + my ( $self, $c ) = @_; + $c->res->body( "Hi! hit number " . ++$c->session->{counter} ); + } + + sub stream : Local { + my ( $self, $c ) = @_; + my $count = ++$c->session->{counter}; + $c->res->write("hit number "); + $c->res->write($count); + } + + sub deleteme : Local { + my ( $self, $c ) = @_; + my $id = $c->get_session_id; + $c->delete_session; + my $id2 = $c->get_session_id; + $c->res->body( $id ne ( $id2 || '' ) ); + } + + __PACKAGE__->setup; +} + +use Test::WWW::Mechanize::Catalyst qw/CookieTestApp/; + +my $m = Test::WWW::Mechanize::Catalyst->new; + +$m->get_ok( "http://localhost/stream", "get page" ); +$m->content_contains( "hit number 1", "session data created" ); + +my $expired; +$m->cookie_jar->scan( sub { $expired = $_[8]; } ); + +$m->get_ok( "http://localhost/page", "get page" ); +$m->content_contains( "hit number 2", "session data restored" ); + +$m->get_ok( "http://localhost/stream", "get stream" ); +$m->content_contains( "hit number 3", "session data restored" ); + +sleep 1; + +$m->get_ok( "http://localhost/stream", "get page" ); +$m->content_contains( "hit number 4", "session data restored" ); + +my $updated_expired; +$m->cookie_jar->scan( sub { $updated_expired = $_[8]; } ); +cmp_ok( $expired, "<", $updated_expired, "cookie expiration was extended" ); + +$expired = $m->cookie_jar->scan( sub { $expired = $_[8] } ); +$m->get_ok( "http://localhost/page", "get page again"); +$m->content_contains( "hit number 5", "session data restored (blah)" ); + +sleep 1; + +$m->get_ok( "http://localhost/stream", "get stream" ); +$m->content_contains( "hit number 6", "session data restored" ); + +$m->cookie_jar->scan( sub { $updated_expired = $_[8]; } ); +cmp_ok( $expired, "<", $updated_expired, "streaming also extends cookie" ); + +$m->get_ok( "http://localhost/deleteme", "get page" ); +$m->content_is( 1, 'session id changed' ); diff --git a/t/live_app_session.t b/t/live_app_session.t new file mode 100755 index 0000000..14b55e3 --- /dev/null +++ b/t/live_app_session.t @@ -0,0 +1,88 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More; +use Data::Dumper; +local $Data::Dumper::Sortkeys = 1; + +BEGIN { + eval { + require Catalyst::Plugin::Session::State::Cookie; + Catalyst::Plugin::Session::State::Cookie->VERSION(0.03); + } + or plan skip_all => + "Catalyst::Plugin::Session::State::Cookie 0.03 or higher is required for this test"; + + eval { require Test::WWW::Mechanize::Catalyst } + or plan skip_all => + "Test::WWW::Mechanize::Catalyst is required for this test"; + + plan tests => 42; +} + +use lib "t/lib"; +use Test::WWW::Mechanize::Catalyst "SessionTestApp"; + +my $ua1 = Test::WWW::Mechanize::Catalyst->new; +my $ua2 = Test::WWW::Mechanize::Catalyst->new; + +$_->get_ok( "http://localhost/page", "initial get" ) for $ua1, $ua2; + +$ua1->content_contains( "please login", "ua1 not logged in" ); +$ua2->content_contains( "please login", "ua2 not logged in" ); + +$_->get_ok( "http://localhost/inspect_session", "check for value in session" ) + for $ua1, $ua2; + +$ua1->content_contains( "value of logged_in is 'undef'", + "check ua1 'logged_in' val" ); +$ua2->content_contains( "value of logged_in is 'undef'", + "check ua2 'logged_in' val" ); + +$_->get_ok( "http://localhost/page", "initial get" ) for $ua1, $ua2; + +$ua1->content_contains( "please login", "ua1 not logged in" ); +$ua2->content_contains( "please login", "ua2 not logged in" ); + +$ua1->get_ok( "http://localhost/login", "log ua1 in" ); +$ua1->content_contains( "logged in", "ua1 logged in" ); + +$_->get_ok( "http://localhost/page", "get main page" ) for $ua1, $ua2; + +$ua1->content_contains( "you are logged in", "ua1 logged in" ); +$ua2->content_contains( "please login", "ua2 not logged in" ); + +$ua2->get_ok( "http://localhost/login", "get main page" ); +$ua2->content_contains( "logged in", "log ua2 in" ); + +$_->get_ok( "http://localhost/page", "get main page" ) for $ua1, $ua2; + +$ua1->content_contains( "you are logged in", "ua1 logged in" ); +$ua2->content_contains( "you are logged in", "ua2 logged in" ); + +$_->get_ok( "http://localhost/page", "get main page" ) for $ua1, $ua2; +$ua1->content_contains( "you are logged in", "ua1 logged in" ); +$ua2->content_contains( "you are logged in", "ua2 logged in" ); + +$ua2->get_ok( "http://localhost/logout", "log ua2 out" ); +$ua2->content_like( qr/logged out/, "ua2 logged out" ); +$ua2->content_like( qr/after 2 request/, + "ua2 made 2 requests for page in the session" ); + +$_->get_ok( "http://localhost/page", "get main page" ) for $ua1, $ua2; + +$ua1->content_contains( "you are logged in", "ua1 logged in" ); +$ua2->content_contains( "please login", "ua2 not logged in" ); + +$ua1->get_ok( "http://localhost/logout", "log ua1 out" ); +$ua1->content_like( qr/logged out/, "ua1 logged out" ); +$ua1->content_like( qr/after 4 requests/, + "ua1 made 4 request for page in the session" ); + +$_->get_ok( "http://localhost/page", "get main page" ) for $ua1, $ua2; + +$ua1->content_contains( "please login", "ua1 not logged in" ); +$ua2->content_contains( "please login", "ua2 not logged in" ); + diff --git a/t/live_simple_session.t b/t/live_simple_session.t new file mode 100755 index 0000000..b0c185b --- /dev/null +++ b/t/live_simple_session.t @@ -0,0 +1,180 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use Test::More; +use Data::Dumper; +local $Data::Dumper::Sortkeys = 1; +use Clone; + +BEGIN { + + eval { require Test::WWW::Mechanize::Catalyst } + or plan skip_all => + "Test::WWW::Mechanize::Catalyst is required for this test"; + + plan tests => 36; +} + +use lib "t/lib"; +use Test::WWW::Mechanize::Catalyst "SessionTestApp"; + +my $ua = Test::WWW::Mechanize::Catalyst->new; + +# initial request - should not set cookie +$ua->get_ok( "http://localhost/page", "initial get" ); +$ua->content_contains( "please login", "ua not logged in" ); +is_deeply get_cookie(), undef, "no cookies yet"; + +# request that checks the session - should not set cookie +$ua->get_ok( "http://localhost/inspect_session", + "check for value in session" ); +$ua->content_contains( "value of logged_in is 'undef'", + "check ua 'logged_in' val" ); +is_deeply get_cookie(), undef, "no cookies yet"; + +# Login - should create a session +$ua->get_ok( "http://localhost/login", "log ua in" ); +$ua->content_contains( "logged in", "ua logged in" ); + +# check that the session cookie created +my $session_id = get_cookie()->{val}; +ok $session_id, "found a session cookie ($session_id)"; + +# check session loaded from store +$ua->get_ok( "http://localhost/page", "get main page" ); +$ua->content_contains( "you are logged in", "ua logged in" ); +is get_cookie()->{val}, $session_id, "session id has not changed"; + +# check that the expires time is updated +{ + my $min_lifetime + = SessionTestApp->config->{session}{min_lifetime}; + my $max_lifetime + = SessionTestApp->config->{session}{max_lifetime}; + + # do some requests until the expires changes + my $original_expiry = get_cookie()->{expires}; + + for ( 1 .. 10 ) { + sleep 1; + $ua->get("http://localhost/inspect_session"); + my $new_expiry = get_cookie()->{expires}; + next if $new_expiry == $original_expiry; + $original_expiry = $new_expiry; + last; + } + + # expiry just updated - check it stays the same + $ua->get_ok( + "http://localhost/inspect_session", + "get page to see expiry not changed" + ); + is get_cookie()->{expires}, $original_expiry, + "expiry is still '$original_expiry'"; + is get_cookie()->{val}, $session_id, "session id has not changed"; + + # sleep so that we go past the min lifetime + ok sleep $_, "sleep $_ so expires get extended" + for $max_lifetime - $min_lifetime + 1; + + # expiry just updated - check it stays the same + $ua->get_ok( + "http://localhost/inspect_session", + "get page to see expiry has changed" + ); + my $new_expiry = get_cookie()->{expires}; + cmp_ok $new_expiry, '>', $original_expiry, + "expiry updated to '$new_expiry'"; + is get_cookie()->{val}, $session_id, "session id has not changed"; + + # sleep beyond the lifetime and see that the session gets expired + ok sleep $_, "sleep $_ so session is too old" for $max_lifetime + 2; + $ua->get_ok( + "http://localhost/inspect_session", + "get page to see session expired" + ); + is get_cookie(), undef, "Cookie has been reset"; + +} + +# check that a session that is not in the db is deleted + +my @session_ids_to_test = ( + 'a' x 40, # valid session id + 'This is not valid @#$%^&', # bad value +); + +foreach my $new_session_id (@session_ids_to_test) { + + pass "--- Testing session_id '$new_session_id' ---"; + + $ua->get_ok( "http://localhost/login", "log ua in" ); + $ua->content_contains( "logged in", "ua logged in" ); + + my $session_id = get_cookie()->{val}; + ok $session_id, "have session_id '$session_id'"; + + # change the value in the cookie to a valid value + ok set_cookie_val($new_session_id), + "change cookie value to '$new_session_id'"; + + # check that the cookie gets deleted + $ua->get_ok( + "http://localhost/inspect_session", + "get page to see if session is deleted" + ); + is get_cookie(), undef, "Cookie has been reset"; + +} + +############################################################################# + +sub get_cookie { + my $cookie_jar = $ua->cookie_jar; + + my $cookie_data = undef; + + $cookie_jar->scan( + sub { + my ($version, $key, $val, $path, + $domain, $port, $path_spec, $secure, + $expires, $discard, $hash + ) = @_; + + # warn "cookie key: $key"; + + if ( $key eq 'sessiontestapp_session' ) { + $cookie_data = { + val => $val, + expires => $expires, + }; + } + } + ); + + return $cookie_data; +} + +sub set_cookie_val { + my $new_val = shift; + my $cookie_jar = $ua->cookie_jar; + + $cookie_jar->scan( + sub { + my ( $version, $key, $val, $path, $domain ) = @_; + + # warn "cookie key: $key"; + + if ( $key eq 'sessiontestapp_session' ) { + + $cookie_jar->set_cookie( $version, $key, $new_val, $path, + $domain ); + + } + } + ); + + return 1; +}