The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
#!/usr/bin/perl

use Test::More tests => 78;
use Carp;

use strict;
use warnings;

use Net::DAV::Server ();
use Net::DAV::LockManager::Simple ();
use XML::LibXML;
use XML::LibXML::XPathContext;
 
my $parser = XML::LibXML->new();

{
    package Mock::Filesys;
    sub new {
        return bless {
            '/' =>                 [ 0, 0, 01777, 2, 1, 1, 0,    0, (time)x3, 1024, 1 ],
            '/foo' =>              [ 0, 0, 01777, 2, 1, 1, 0,    0, (time)x3, 1024, 1 ],
            '/foo/bar' =>          [ 0, 0, 01777, 2, 1, 1, 0,    0, (time)x3, 1024, 1 ],
            '/foo/bar/baz.html' => [ 0, 0, 0666,  1, 1, 1, 0, 1024, (time)x3, 1024, 1 ],
            '/test.html' =>        [ 0, 0, 0666,  1, 1, 1, 0, 1024, (time)x3, 1024, 1 ],
            '/foo/index.html' =>   [ 0, 0, 0666,  1, 1, 1, 0, 2048, (time)x3, 1024, 2 ],
            '/bar' =>              [ 0, 0, 01777, 2, 1, 1, 0,    0, (time)x3, 1024, 1 ],
        };
    }
    sub test {
        my ($self, $op, $path) = @_;

        if ( $op eq 'e' ) {
            return exists $self->{$path};
        }
        elsif ( $op eq 'd' ) {
            return unless exists $self->{$path};
            return (($self->{$path}->[2]&01000) ? 1 : 0);
        }
        else {
            die "Operation $op not implemented.";
        }
    }
    sub stat {
        my ($self, $path) = @_;

        return unless exists $self->{$path};
        return @{$self->{$path}};
    }
}

# Directory
{
    my $label = 'Depth 1 dir, default';
    my $path = '/';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );
    my $fs = $dav->filesys;

    ok( $fs->test( 'e', $path ), "$label: root directory exists" );
    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $req = propfind_request( $path );

    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:href', $path, "$label: Path is correct" );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]',
        [ qw/creationdate getcontentlength getcontenttype getlastmodified supportedlock lockdiscovery resourcetype/ ],
        "$label: Property nodes"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:supportedlock/D:lockentry/D:lockscope/D:exclusive',
        "$label: supported scopes"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

{
    my $label = 'Depth 1 dir, allprop';
    my $path = '/';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );
    my $fs = $dav->filesys;

    ok( $fs->test( 'e', $path ), "$label: root directory exists" );
    my $req = propfind_request(
        $path,
        '<D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>'
    );

    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:href', $path, "$label: Path is correct" );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]',
        [ qw/creationdate getcontentlength getcontenttype getlastmodified supportedlock lockdiscovery resourcetype/ ],
        "$label: Property nodes"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:supportedlock/D:lockentry/D:lockscope/D:exclusive',
        "$label: supported scopes"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

{
    my $label = 'Directory, lockdiscovery';
    my $path = '/';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );

    my $req = propfind_request(
        $path,
        '<D:propfind xmlns:D="DAV:"><D:prop><D:lockdiscovery/></D:prop></D:propfind>'
    );

    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        "$label: Property exists"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

# File
{
    my $label = 'File, default';
    my $path = '/test.html';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );
    my $fs = $dav->filesys;

    ok( $fs->test( 'e', $path ), "$label: file exists" );
    my $req = propfind_request( $path );

    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:href', $path, "$label: Path is correct" );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]',
        [ qw/creationdate getcontentlength getcontenttype getlastmodified supportedlock lockdiscovery resourcetype/ ],
        "$label: Property nodes"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:supportedlock/D:lockentry/D:lockscope/D:exclusive',
        "$label: supported scopes"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

{
    my $label = 'File, allprop';
    my $path = '/test.html';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );
    my $fs = $dav->filesys;

    ok( $fs->test( 'e', $path ), "$label: file exists" );
    my $req = propfind_request(
        $path,
        '<D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>'
    );

    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:href', $path, "$label: Path is correct" );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]',
        [ qw/creationdate getcontentlength getcontenttype getlastmodified supportedlock lockdiscovery resourcetype/ ],
        "$label: Property nodes"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:supportedlock/D:lockentry/D:lockscope/D:exclusive',
        "$label: supported scopes"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

{
    my $label = 'File, lockdiscovery';
    my $path = '/test.html';
    my $dav = Net::DAV::Server->new( -filesys => Mock::Filesys->new(), -dbobj => Net::DAV::LockManager::Simple->new() );

    my $lresp = $dav->lock( lock_request( $path ), HTTP::Response->new( 200 ) );
    ok( $lresp && $lresp->code == 200, "$label: root directory locked." );

    my $req = propfind_request(
        $path,
        '<D:propfind xmlns:D="DAV:"><D:prop><D:lockdiscovery/></D:prop></D:propfind>'
    );
    my $resp = $dav->propfind( $req, HTTP::Response->new( 200, 'OK' ) );
    is( $resp->code, 207, "$label: Response is 'Multi-Status'" );
    my $xpc = get_xml_context( $resp->content );
    has_text( $xpc, '/D:multistatus/D:response/D:propstat/D:status', 'HTTP/1.1 200 OK', "$label: Status is correct" );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        "$label: Property exists"
    );
    has_nodes( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop[1]/D:lockdiscovery',
        [ qw/activelock/ ],
        "$label: locks"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktype/D:write',
        "$label: correct type"
    );
    has_node( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockscope/D:exclusive',
        "$label: correct scope"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:depth',
        'infinity',
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:timeout',
        qr/Second-\d+/,
        "$label: correct timeout form"
    );
    like_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:locktoken/D:href',
        qr/opaquelocktoken:[-\da-f]+/,
        "$label: correct token form"
    );
    has_text( $xpc,
        '/D:multistatus/D:response/D:propstat/D:prop/D:lockdiscovery/D:activelock/D:lockroot/D:href',
        $path,
        "$label: root is correct"
    );
}

# -------- Utility subs ----------

sub propfind_request {
    my ($path, $content) = @_;

    my $req = HTTP::Request->new( PROPFIND => $path );
    if ( defined $content ) {
        $req->content( $content );
        $req->header( 'Content-Length', length $content );
    }
    $req->authorization_basic( 'fred', 'fredmobile' );
    return $req;
}

sub get_xml_context {
    my ($content) = @_;
    my $doc = eval { $parser->parse_string( $content ) };
    die "Unable to parse content.\n" unless defined $doc;
    my $xpc = XML::LibXML::XPathContext->new( $doc );
    $xpc->registerNs( 'D', 'DAV:' );
    return $xpc;
}

sub has_text {
    my ($xpc, $xpath, $expect, $label) = @_;
    my @nodes = $xpc->findnodes( "$xpath/text()" );
    unless ( defined $nodes[0] ) {
        fail( "$label : Node not found." );
        return;
    }
    is( $nodes[0]->data, $expect, $label );
}

sub like_text {
    my ($xpc, $xpath, $expect, $label) = @_;
    my @nodes = $xpc->findnodes( "$xpath/text()" );
    unless ( defined $nodes[0] ) {
        fail( "$label : Node not found." );
        return;
    }
    like( $nodes[0]->data, $expect, $label );
}

sub has_texts {
    my ($xpc, $xpath, $expect, $label) = @_;
    my @nodes = map { $_->data } $xpc->findnodes( "$xpath/text()" );
    unless ( defined $nodes[0] ) {
        fail( "$label : Node not found." );
        return;
    }
    is_deeply( \@nodes, $expect, $label );
}

sub has_nodes {
    my ($xpc, $xpath, $tags, $label) = @_;
    my @nodes = map { $_->localname } $xpc->findnodes( "$xpath/D:*" );
    unless ( defined $nodes[0] ) {
        fail( "$label : Node not found." );
        return;
    }
    is_deeply( \@nodes, $tags, $label );
}

sub has_node {
    my ($xpc, $xpath, $label) = @_;
    my @nodes = $xpc->findnodes( $xpath );
    ok ( defined $nodes[0], $label );
}

sub lock_request {
    my ($uri, $args) = @_;
    my $req = HTTP::Request->new( 'LOCK' => $uri, (exists $args->{timeout}?[ 'Timeout' => $args->{timeout} ]:()) );
    $req->authorization_basic( 'fred', 'fredmobile' );
    if ( $args ) {
        my $scope = $args->{scope} || 'exclusive';
        $req->content( <<"BODY" );
<?xml version="1.0" encoding="utf-8"?>
<D:lockinfo xmlns:D='DAV:'>
    <D:lockscope><D:$scope /></D:lockscope>
    <D:locktype><D:write/></D:locktype>
    <D:owner>
        <D:href>http://fred.org/</D:href>
    </D:owner>
</D:lockinfo>
BODY
    }

    return $req;
}