Testing with Apache::Test

When learning about mod_perl and Apache, it helps to apply what you've studied by writing your own handlers. In this vein, I set out to write a set of modules to provide comprehensive access, authentication and authorization functionality. Where are these wondrous modules? -It's all about the journey itself right, not whether or not the destination is reached?

Where better to start than a simple translation phase handler to set a session identifier in a cookie, or failing that embedded in the url itself? The fruits of that labor, AxS::Session, are listed in the next section.

Once you've written your Apache module, exposed it to a bit of peer review, and cleaned it up until it's presentable... Is it ready to be shared with the CPAN masses? ...Did you write any tests? What? You say, Apache modules are nigh untestable? Not anymore! With Stas Beckman's Apache::Test framework in hand I hope to show by example how it can be done.

In short, Apache::Test provides a framework for testing Apache based applications and components. The framework provides a self-contained environment for running Apache with test specific configurations and provides some additional HTTP testing tools. All of which is accessible via the familiar "make test" procedure most people already use when downloading and installing modules from CPAN.

AxS::Session

Synopsis

AxS::Session is a mod_perl translation phase handler. It generates unique session identifiers which are set and retrieved from cookies or embedded within URLs. The identifier is then accessible via the Apache request object for the life of the request. The most common use of unique session identifiers is to serve as a key for the storage of persistent client data.

Add the following to your httpd.conf (or equivalent) and restart apache:

PerlModule AxS::Session

PerlTransHandler AxS::Session

And in code executed after the translation phase:

my $session_id = $r->notes('AxS_SESSION');


01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package AxS::Session;
use Digest::MD5 qw(md5_hex);
use Apache::Constants qw(DECLINED REDIRECT OK);

$VERSION = '0.03';
$COOKIE  = $PATH = 1;             # Support embedding session identifier in
$EXPIRE  = 24 * 60 * 60;          # seconds in 24 hours
$NAME    = 'AxS_SESSION';
$MATCH   = undef; # qr/\.html?$/; # session id's only for matching uri's

sub handler {
  my $r = shift;
  return DECLINED  unless $r->is_initial_req;
  my ($id, %args);
  %args = $r->args;

  if ($COOKIE) {
    my $h = $r->header_in('Cookie');
    if ($h) {
      /^\Q$NAME\E=([0-9a-f]{32})$/ and $id = $1 and last  for split /;\s*/, $h;
    }
    $r->notes('AxS_COOKIE' => $id ? 1 : 0);
    if ($id and exists $args{'CookieCheck'}) {
      delete $args{'CookieCheck'}; # leave a clean uri
      my $q = join ';', map {"$_=$args{$_}"} keys %args;
      $r->header_out('Location', $r->uri . ($q ? '?' . $q : ''));
      return REDIRECT
    }
  }

  if ($PATH) { # relative path css, etc. need id pruned from uri regardless
    $id = $1 and $r->uri("$2")  if $r->uri =~ m#^/([0-9a-f]{32})(/.*)#;
  }

  if ($id) {
    $r->notes($NAME => $id);
    return DECLINED;
  }

  return DECLINED  if defined $MATCH and $r->uri !~ m/$MATCH/;

  $id = md5_hex($$.time.{}.rand()); # Make session id

  if (not exists $args{'CookieCheck'}) {
    $r->err_header_out('Set-Cookie', 
                       join(';',
                       $NAME .'=' . $id,
                       'expires=' . scalar gmtime(time + $EXPIRE),
                       'path=/',
                       'domain='  . $r->get_server_name,));
    $r->header_out('Location',
                   $r->uri.'?'.($r->args?$r->args.';':'').'CookieCheck=1');
  } else { # mangle uri and redirect
    delete $args{'CookieCheck'};
    my $q = join ';', map {"$_=$args{$_}"} keys %args;
    $r->header_out('Location', "/$id" . $r->uri . ($q ? '?' . $q : ''));
  }
  return REDIRECT;
}
1;
__END__

Code Commentary

In the case that cookies are supported, the first request from a client for the website will generate 2 redirects. The first redirect attempts to set a session identifier assignment via a cookie, leaving a trace of this attempt in the query string. The second redirect is required to return the query string to its initial state. All subsequent request do not require redirects. At least until the cookie expires.

When cookies aren't supported, the second redirect will instead issue a redirect for a URL with an embedded session identifier. If AxS::Session cookie support were explicitly disabled for all clients, only one redirect would be necessary. With URL embedded identifiers, it is of paramount importance that pages served dynamically rewrite references to local URLs to perpetuate the session. Whereas cookies persist between sessions, it is likely that unless bookmarked, each initial access to the site by a browser will require at least one redirect.

lines 1-5 Set the package name, version, and included a few useful modules

lines 6-9 Set up a few global configuration variables, setting the stage for a future version to rework them into Apache Configuration Directives. $COOKIE and $PATH if true, enable insertion of session identifiers into cookies and URLs respectively. $EXPIRE sets how long generated cookies will last. $NAME is the key by which the session identifier may be recalled from $r->notes. And $MATCH, a regex which the URL must match before a session identifier will be generated. By default, all URLs are candidates for session identifiers. If you don't have a separate image server or web server for handling static files this may not be desirable. In such cases, you should set $MATCH to match whatever URLs you intend to handle session specific requests.

lines 11-15 start the handler off on a good footing. Handlers receive the Apache request object which by convention is assigned to $r. Line 13 checks to make sure this is the first time it has been invoked for this request, and short-circuiting to avoid unnecessary work if otherwise. Internal redirects are an example of where this code would be exercised. Next lexical variables $id and %args are created, and %args is given the query string's contents if any.

lines 17-22 look for a pre-existing cookie session identifier in the request's headers; short-circuiting as soon as it is found. If an identifier is found, it is stored in $id. Last, we stash a note away in $r->notes(AxS_Cookie) letting us know if cookies are enabled or not. Something that'll be useful later when you're deciding whether or not to rewrite references to local URLs to embed the session identifier.

lines 23-28 are a bit tricky. If a session identifier wasn't found in a cookie on the first pass, code a little further down is going try to set the cookie along with an argument in the query string and issue a redirect. The reason for this, is that there is no clear way to determine if cookies are enabled other than to try setting one. So, these lines use the 'CookieCheck' parameter to recognize the second pass where we've explicitly attempted to set a cookie. If the session identifier is found, we redirect the request back to a cleaned-up URL (sans 'CookieCheck').

lines 31-33 To protect ourselves from relative URLs, we check and remove session identifiers from all URLs not just those that match $MATCH. If an identifier was found it is stored in $id.

lines 35-38 If a session identifier has been found, set the request note and return DECLINED to let the request pass through to the next handler.

line 40 Return DECLINED if the URL doesn't match MATCH. I.e., only allow those requests to fall through which we wish to have session identifiers.

line 42 Borrowed from Apache::Session, this line uses the Digest::MD5 module to create and assign a unique identifier to $id. The md5 hash is based on the process id, current time, the memory address of an anonymous hash, and a random number.

lines 44-58 Here's where the tricky stuff I alluded to earlier happens. If this is the first pass through and a session identifier wasn't found, we set the cookie and add 'CookieCheck' to the query string in the Location field in the header so we can recognize the attempt. On the other hand, if we reached this point with 'CookieCheck' in the query string, then we know cookies are unavailable. So we add the identifier to the URL and remove 'CookieCheck' from the Location field in the header. Finally, we issue our redirect.


Apache::Test

Why Test?

It is all to easy to hammer out a bit of code for a feature, try it out to see if it works, and move on to the next feature. When we build things we like to see them work. It is much more difficult to convince many programmers to put the code creator hat aside, and put the test demolition hat in its place. But it is important.

When you're testing, its your job to break the code. That gives you a fundamentally different perspective. In many cases it is advisable to have a different person test code than the one who wrote it. Because for many people it is simply too hard for them to look at the code they just wrote and try to tear it apart.

Still, that's exactly what I'm advising you do. And many experienced programmers will back me up here when I say you'll thank yourself for it. Like many things, there is a balance to be had... but in general, the easier it is to regularly and thoroughly test your code, the faster development will go. ...Really! I'm not kidding.

When you're finding more bugs up-front, that pays dividends when you find yourself spending less time backtracking through old code you hardly recognize. When you facilitate the ability of your code's user base to run tests on their varied configurations you'll get better feedback and bug reports. Not to mention access to cross-platform feedback you'd be hard pressed to get otherwise. And it is easier to track down bugs as new features are introduced.

So I was already preaching to the choir, or perhaps I've got you half convinced. Or maybe its just late at night, and you were hoping you'd already be asleep by now? So how do you test an Apache mod_perl module?

Getting Started with Apache::Test

Open a shell, make your way to a comfortable location and type:


    h2xs -AXn AxS::Session

h2xs is utility used originally to convert C header files into Perl extension (XS) code along with boiler plate files for new modules. Forgoing the C and XS aspects, which is what the -AX flags do, it is very useful when you wish to create a module. Executing the above results in the following:


    Writing AxS/Session/Session.pm
    Writing AxS/Session/Makefile.PL
    Writing AxS/Session/README
    Writing AxS/Session/test.pl
    Writing AxS/Session/Changes
    Writing AxS/Session/MANIFEST

To adapt the module boiler plate to support Apache::Test you need to do a few things. First delete test.pl. Apache::Test needs its own test directory. Next we need to shuffle file locations about a bit to facilitate Apache accessing the module during testing.

Move Session up to the same directory as its parent AxS, then delete AxS. Make a 'lib/AxS' directory under Session, and move Session.pm into it. You can then cut and paste the code listing for AxS::Session into Session.pm.

Next, modify the standard Makefile.PL to enable Apache::Test as follows.

Before:

    use 5.008;
    use ExtUtils::MakeMaker;
    # See lib/ExtUtils/MakeMaker.pm for details of how to influence
    # the contents of the Makefile that is written.
    WriteMakefile(
        'NAME'         => 'AxS::Session',
        'VERSION_FROM' => 'Session.pm', # finds $VERSION
        'PREREQ_PM'    => {}, # e.g., Module::Name => 1.1
        ($] >= 5.005 ?    ## Add these new keywords supported since 5.005
          (ABSTRACT_FROM => 'Session.pm', # retrieve abstract from module
           AUTHOR        => 'Garrett Goebel <garrett at scriptpro dot com>') : ()),
    );

After:

    use 5.008;
    use ExtUtils::MakeMaker;
    # See lib/ExtUtils/MakeMaker.pm for details of how to influence
    # the contents of the Makefile that is written.
    
    use lib qw(../blib/lib lib);
    use Apache::TestMM qw(test clean);
    use Apache::TestRunPerl ();
    
    Apache::TestMM::filter_args();
    Apache::TestRunPerl->generate_script();
    
    WriteMakefile(
        'NAME'	 => 'AxS::Session',
        'VERSION_FROM'	=> 'lib/AxS/Session.pm', # finds $VERSION
        'PREREQ_PM'	 => { 'Apache::Test' => ''},
        'clean'             => { 'FILES' => 't/Test' },
        ($] >= 5.005 ?
          (ABSTRACT_FROM => 'lib/AxS/Session.pm',
           AUTHOR     => 'Garrett Goebel <garrett at scriptpro dot com>') : ()),

);

Next, create the t/TEST.PL file with the following contents:


    #file:t/TEST.PL
    #--------------
    #!perl
    use strict;
    use warnings FATAL => 'all';
    use lib qw(lib);
    use Apache::TestRunPerl ();

    Apache::TestRunPerl->new->run(@ARGV);

You may notice the line "#!perl". When t/TEST.PL is used to generate t/TEST this will be replace with the location of the version of Perl used.

Apache needs a minimal configuration. Apache::Test provides the following defaults:

ServerRoot  t/

DocumentRoot t/htdocs

ErrorLog  t/logs/error_log

Listen  8529

You can supplement the defaults with custom configuration directives. Create a directory t/conf. In this directory create an extra.conf.in file using the following listing. extra.conf.in can make use of the same directives you would find in httpd.conf. In fact, it is pulled into the httpd.conf via an Include directive.

#file:t/conf/extra.conf.in

#-------------------------

# this file will be Include-d by @ServerRoot@/httpd.conf

PerlModule AxS::Session

PerlTransHandler AxS::Session

The pain is now over. Get started writing tests as you would for Test, Test::More, or Test::Harness. For example, create a file t/1.t:

use Test;

BEGIN { plan tests => 1 };

use AxS::Session;

ok(1); # If we made it this far, we're ok.

Now recite the familiar mantra:

perl Makefile.PL

make

make test

to see Apache::Test in action.

rx Session # make test

/usr/bin/perl -Iblib/arch -Iblib/lib \

t/TEST -clean

*** setting ulimit to allow core files

ulimit -c unlimited; t/TEST -clean

APACHE_USER= APACHE_GROUP= APACHE_PORT= APACHE= APXS= \

/usr/bin/perl -Iblib/arch -Iblib/lib \

t/TEST -verbose=0

*** setting ulimit to allow core files

ulimit -c unlimited; t/TEST -verbose=0

*** root mode: changing the files ownership to 'nobody' (65534:65534)

*** sudo -u '#65534' /usr/bin/perl -e 'print -r "/home/ggoebel/src/AxS/Session/t" &&  -w _ && -x _ ? "OK" : "NOK"'

*** result: OK

/usr/sbin/apache -X -d /home/ggoebel/src/AxS/Session/t -f /home/ggoebel/src/AxS/Session/t/conf/httpd.conf -DAPACHE1

using Apache/1.3.28

waiting for server to start: .

waiting for server to start: ok (waited 0 secs)

server localhost:8529 started

1....ok

All tests successful.

Files=1, Tests=1,  0 wallclock secs ( 0.06 cusr +  0.02 csys =  0.08 CPU)

*** root mode: restoring the original files ownership

*** server localhost:8529 shutdown

t/TEST is generated after "perl Makefile.PL" as a result of the line

Apache::TestRunPerl->generate_script();

So it isn't strictly necessary to do a "make test" to execute the test suite. A simple "t/TEST" will suffice. Apache::Test's t/TEST can be called with a plethora of options that can facilitate debugging:

t/TEST -start-httpd

t/TEST -get /index.html

t/TEST -stop-httpd

To see the full list of what's available, try:

t/TEST -help

For most tests however, you'll want to use Apache::Test instead of Test, Test::More, and the like. Rest assured however that Apache::Test gives you all that you were used to and more.  Create a t/2.t file containing the following:

use Apache::Test;

use Apache::TestRequest;

plan tests => 1, (have_lwp);

my $url = '/index.html';

my $response = GET $url;

ok $response->code == 200;

Summary

Admittedly, I just scratched the surface. But hopefully I've scratched your itch, and you'll be wanting to know more. The best references for Apache::Test information are: