]> Sergey Matveev's repositories - public-inbox.git/blobdiff - t/imapd.t
imap: FETCH: try to make fake MSNs sequentially
[public-inbox.git] / t / imapd.t
index 31154bdf3e75d78508205c1dd70351ecc25e9991..fdab074a249f292936bc952bfb473fd7601f3180 100644 (file)
--- a/t/imapd.t
+++ b/t/imapd.t
@@ -1,6 +1,7 @@
 #!perl -w
 # Copyright (C) 2020 all contributors <meta@public-inbox.org>
 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+# end-to-end IMAP tests, see unit tests in t/imap.t, too
 use strict;
 use Test::More;
 use Time::HiRes ();
@@ -8,6 +9,15 @@ use PublicInbox::TestCommon;
 use PublicInbox::Config;
 use PublicInbox::Spawn qw(which);
 require_mods(qw(DBD::SQLite Mail::IMAPClient Mail::IMAPClient::BodyStructure));
+my $imap_client = 'Mail::IMAPClient';
+my $can_compress = $imap_client->can('compress');
+if ($can_compress) { # hope this gets fixed upstream, soon
+       require PublicInbox::IMAPClient;
+       $imap_client = 'PublicInbox::IMAPClient';
+}
+
+require_ok 'PublicInbox::IMAP';
+my $first_range = '0';
 
 my $level = '-Lbasic';
 SKIP: {
@@ -56,7 +66,7 @@ my %mic_opt = (
        Port => $sock->sockport,
        Uid => 1,
 );
-my $mic = Mail::IMAPClient->new(%mic_opt);
+my $mic = $imap_client->new(%mic_opt);
 my $pre_login_capa = $mic->capability;
 is(grep(/\AAUTH=ANONYMOUS\z/, @$pre_login_capa), 1,
        'AUTH=ANONYMOUS advertised pre-login');
@@ -70,7 +80,7 @@ ok(join("\n", @$pre_login_capa) ne join("\n", @$post_login_capa),
 
 $mic_opt{Authmechanism} = 'ANONYMOUS';
 $mic_opt{Authcallback} = sub { '' };
-$mic = Mail::IMAPClient->new(%mic_opt);
+$mic = $imap_client->new(%mic_opt);
 ok($mic && $mic->login && $mic->IsAuthenticated, 'AUTHENTICATE ANONYMOUS');
 my $post_auth_anon_capa = $mic->capability;
 is_deeply($post_auth_anon_capa, $post_login_capa,
@@ -80,73 +90,27 @@ ok(!$mic->examine('foo') && ($e = $@), 'EXAMINE non-existent');
 like($e, qr/\bNO\b/, 'got a NO on EXAMINE for non-existent');
 ok(!$mic->select('foo') && ($e = $@), 'EXAMINE non-existent');
 like($e, qr/\bNO\b/, 'got a NO on EXAMINE for non-existent');
-ok($mic->select('inbox.i1'), 'SELECT succeeds');
-ok($mic->examine('inbox.i1'), 'EXAMINE succeeds');
-my @raw = $mic->status('inbox.i1', qw(Messages uidnext uidvalidity));
+my $mailbox1 = "inbox.i1.$first_range";
+ok($mic->select('inbox.i1'), 'SELECT on parent succeeds');
+ok($mic->select($mailbox1), 'SELECT succeeds');
+ok($mic->examine($mailbox1), 'EXAMINE succeeds');
+my @raw = $mic->status($mailbox1, qw(Messages uidnext uidvalidity));
 is(scalar(@raw), 2, 'got status response');
-like($raw[0], qr/\A\*\x20STATUS\x20inbox\.i1\x20
+like($raw[0], qr/\A\*\x20STATUS\x20inbox\.i1\.$first_range\x20
        \(MESSAGES\x20\d+\x20UIDNEXT\x20\d+\x20UIDVALIDITY\x20\d+\)\r\n/sx);
 like($raw[1], qr/\A\S+ OK /, 'finished status response');
 
-@raw = $mic->list;
-like($raw[0], qr/^\* LIST \(.*?\) "\." inbox/,
+my @orig_list = @raw = $mic->list;
+like($raw[0], qr/^\* LIST \(.*?\) "\." INBOX/,
        'got an inbox');
 like($raw[-1], qr/^\S+ OK /, 'response ended with OK');
-is(scalar(@raw), scalar(@V) + 2, 'default LIST response');
+is(scalar(@raw), scalar(@V) + 4, 'default LIST response');
 @raw = $mic->list('', 'inbox.i1');
 is(scalar(@raw), 2, 'limited LIST response');
-like($raw[0], qr/^\* LIST \(.*?\) "\." inbox/,
+like($raw[0], qr/^\* LIST \(.*?\) "\." INBOX/,
                'got an inbox.i1');
 like($raw[-1], qr/^\S+ OK /, 'response ended with OK');
 
-{ # make sure we get '%' globbing right
-       my @n = map { { newsgroup => $_ } } (qw(x.y.z x.z.y));
-       my $self = { imapd => { grouplist => \@n } };
-       PublicInbox::IMAPD::refresh_inboxlist($self->{imapd});
-       my $res = PublicInbox::IMAP::cmd_list($self, 'tag', 'x', '%');
-       is(scalar($$res =~ tr/\n/\n/), 2, 'only one result');
-       like($$res, qr/ x\r\ntag OK/, 'saw expected');
-       $res = PublicInbox::IMAP::cmd_list($self, 'tag', 'x.', '%');
-       is(scalar($$res =~ tr/\n/\n/), 3, 'only one result');
-       is(scalar(my @x = ($$res =~ m/ x\.[zy]\r\n/g)), 2, 'match expected');
-
-       $res = PublicInbox::IMAP::cmd_list($self, 't', 'x.(?{die "RCE"})', '%');
-       like($$res, qr/\At OK /, 'refname does not match attempted RCE');
-       $res = PublicInbox::IMAP::cmd_list($self, 't', '', '(?{die "RCE"})%');
-       like($$res, qr/\At OK /, 'wildcard does not match attempted RCE');
-}
-
-if ($ENV{TEST_BENCHMARK}) {
-       use Benchmark qw(:all);
-       my @n = map { { newsgroup => "inbox.comp.foo.bar.$_" } } (0..50000);
-       push @n, map { { newsgroup => "xobni.womp.foo.bar.$_" } } (0..50000);
-       my $self = { imapd => { grouplist => \@n } };
-       PublicInbox::IMAPD::refresh_inboxlist($self->{imapd});
-
-       my $n = scalar @n;
-       open my $null, '>', '/dev/null' or die;
-       my $ds = { sock => $null };
-       my $nr = 200;
-       diag "starting benchmark...";
-       my $t = timeit(1, sub {
-               for (0..$nr) {
-                       my $res = PublicInbox::IMAP::cmd_list($self, 'tag',
-                                                               '', '*');
-                       PublicInbox::DS::write($ds, $res);
-               }
-       });
-       diag timestr($t). "list all for $n inboxes $nr times";
-       $nr = 20;
-       $t = timeit(1, sub {
-               for (0..$nr) {
-                       my $res = PublicInbox::IMAP::cmd_list($self, 'tag',
-                                                               'inbox.', '%');
-                       PublicInbox::DS::write($ds, $res);
-               }
-       });
-       diag timestr($t). "list partial for $n inboxes $nr times";
-}
-
 my $ret = $mic->search('all') or BAIL_OUT "SEARCH FAIL $@";
 is_deeply($ret, [ 1 ], 'search all works');
 $ret = $mic->search('uid 1') or BAIL_OUT "SEARCH FAIL $@";
@@ -157,6 +121,19 @@ $ret = $mic->search('uid 1:*') or BAIL_OUT "SEARCH FAIL $@";
 is_deeply($ret, [ 1 ], 'search UID 1:* works');
 
 is_deeply(scalar $mic->flags('1'), [], '->flags works');
+{
+       # RFC 3501 section 6.4.8 states:
+       # Also note that a UID range of 559:* always includes the
+       # UID of the last message in the mailbox, even if 559 is
+       # higher than any assigned UID value.
+       my $exp = $mic->fetch_hash(1, 'UID');
+       $ret = $mic->fetch_hash('559:*', 'UID');
+       is_deeply($ret, $exp, 'beginning range too big');
+       for my $r (qw(559:558 558:559)) {
+               $ret = $mic->fetch_hash($r, 'UID');
+               is_deeply($ret, {}, "out-of-range UID FETCH $r");
+       }
+}
 
 for my $r ('1:*', '1') {
        $ret = $mic->fetch_hash($r, 'RFC822') or BAIL_OUT "FETCH $@";
@@ -191,26 +168,48 @@ for my $r ('1:*', '1') {
        $ret = $mic->fetch_hash($r, 'FLAGS') or BAIL_OUT "FETCH $@";
        is_deeply($ret->{1}->{FLAGS}, '', 'no flags');
 
+       $ret = $mic->fetch_hash($r, 'BODY[1]') or BAIL_OUT "FETCH $@";
+       like($ret->{1}->{'BODY[1]'}, qr/\AThis is a test message/, 'BODY[1]');
+
+       $ret = $mic->fetch_hash($r, 'BODY[1]<1>') or BAIL_OUT "FETCH $@";
+       like($ret->{1}->{'BODY[1]<1>'}, qr/\Ahis is a test message/,
+                       'BODY[1]<1>');
+
+       $ret = $mic->fetch_hash($r, 'BODY[1]<2.3>') or BAIL_OUT "FETCH $@";
+       is($ret->{1}->{'BODY[1]<2>'}, "is ", 'BODY[1]<2.3>');
+       $ret = $mic->bodypart_string($r, 1, 3, 2) or
+                                       BAIL_OUT "bodypart_string $@";
+       is($ret, "is ", 'bodypart string');
+
+       $ret = $mic->fetch_hash($r, 'BODY[HEADER.FIELDS.NOT (Message-ID)]')
+               or BAIL_OUT "FETCH $@";
+       $ret = $ret->{1}->{'BODY[HEADER.FIELDS.NOT (MESSAGE-ID)]'};
+       unlike($ret, qr/message-id/i, 'Message-ID excluded');
+       like($ret, qr/\r\n\r\n\z/s, 'got header end');
+
+       $ret = $mic->fetch_hash($r, 'BODY[HEADER.FIELDS (Message-ID)]')
+               or BAIL_OUT "FETCH $@";
+       is($ret->{1}->{'BODY[HEADER.FIELDS (MESSAGE-ID)]'},
+               'Message-ID: <testmessage@example.com>'."\r\n\r\n",
+               'got only Message-ID');
+
        my $bs = $mic->get_bodystructure($r) or BAIL_OUT("bodystructure: $@");
        ok($bs, 'got a bodystructure');
        is(lc($bs->bodytype), 'text', '->bodytype');
        is(lc($bs->bodyenc), '8bit', '->bodyenc');
 }
 
-# Mail::IMAPClient ->compress creates cyclic reference:
-# https://rt.cpan.org/Ticket/Display.html?id=132654
-my $compress_logout = sub {
-       my ($c) = @_;
-       ok($c->logout, 'logout ok after ->compress');
-       # all documented in Mail::IMAPClient manpage:
-       for (qw(Readmoremethod Readmethod Prewritemethod)) {
-               $c->$_(undef);
-       }
-};
-
 is_deeply([$mic->has_capability('COMPRESS')], ['DEFLATE'], 'deflate cap');
-ok($mic->compress, 'compress enabled');
-$compress_logout->($mic);
+SKIP: {
+       skip 'Mail::IMAPClient too old for ->compress', 2 if !$can_compress;
+       my $c = $imap_client->new(%mic_opt);
+       ok($c && $c->compress, 'compress enabled');
+       ok($c->examine($mailbox1), 'EXAMINE succeeds after COMPRESS');
+       $ret = $c->search('uid 1:*') or BAIL_OUT "SEARCH FAIL $@";
+       is_deeply($ret, [ 1 ], 'search UID 1:* works after compression');
+}
+
+ok($mic->logout, 'logout works');
 
 my $have_inotify = eval { require Linux::Inotify2; 1 };
 
@@ -220,13 +219,14 @@ $pi_config->each_inbox(sub {
        my $env = { ORIGINAL_RECIPIENT => $ibx->{-primary_address} };
        my $name = $ibx->{name};
        my $ng = $ibx->{newsgroup};
-       my $mic = Mail::IMAPClient->new(%mic_opt);
+       my $mic = $imap_client->new(%mic_opt);
        ok($mic && $mic->login && $mic->IsAuthenticated, "authed $name");
-       my $uidnext = $mic->uidnext($ng); # we'll fetch BODYSTRUCTURE on this
+       my $mb = "$ng.$first_range";
+       my $uidnext = $mic->uidnext($mb); # we'll fetch BODYSTRUCTURE on this
        ok($uidnext, 'got uidnext for later fetch');
        is_deeply([$mic->has_capability('IDLE')], ['IDLE'], "IDLE capa $name");
        ok(!$mic->idle, "IDLE fails w/o SELECT/EXAMINE $name");
-       ok($mic->examine($ng), "EXAMINE $ng succeeds");
+       ok($mic->examine($mb), "EXAMINE $ng succeeds");
        ok(my $idle_tag = $mic->idle, "IDLE succeeds on $ng");
 
        open(my $fh, '<', 't/data/message_embed.eml') or BAIL_OUT("open: $!");
@@ -292,7 +292,95 @@ $pi_config->each_inbox(sub {
        ok($bs, 'BODYSTRUCTURE ok for deeply nested');
        $ret = $mic->fetch_hash($uidnext, 'BODY') or BAIL_OUT "FETCH $@";
        ok($ret->{$uidnext}->{BODY}, 'got something in BODY');
-});
+
+       # this matches dovecot behavior
+       $ret = $mic->fetch_hash($uidnext, 'BODY[1]') or BAIL_OUT "FETCH $@";
+       is($ret->{$uidnext}->{'BODY[1]'},
+               "testing embedded message harder\r\n", 'BODY[1]');
+       $ret = $mic->fetch_hash($uidnext, 'BODY[2]') or BAIL_OUT "FETCH $@";
+       like($ret->{$uidnext}->{'BODY[2]'},
+               qr/\ADate: Sat, 18 Apr 2020 22:20:20 /, 'BODY[2]');
+
+       $ret = $mic->fetch_hash($uidnext, 'BODY[2.1.1]') or BAIL_OUT "FETCH $@";
+       is($ret->{$uidnext}->{'BODY[2.1.1]'},
+               "testing embedded message\r\n", 'BODY[2.1.1]');
+
+       $ret = $mic->fetch_hash($uidnext, 'BODY[2.1.2]') or BAIL_OUT "FETCH $@";
+       like($ret->{$uidnext}->{'BODY[2.1.2]'}, qr/\AFrom: /,
+               'BODY[2.1.2] tip matched');
+       like($ret->{$uidnext}->{'BODY[2.1.2]'},
+                # trailing CRLF may vary depending on MIME parser
+                qr/done_testing;(?:\r\n){1,2}\z/,
+               'BODY[2.1.2] tail matched');
+
+       $ret = $mic->fetch_hash("1:$uidnext", 'BODY[2.HEADER]') or
+                                               BAIL_OUT "2.HEADER $@";
+       like($ret->{$uidnext}->{'BODY[2.HEADER]'},
+               qr/\ADate: Sat, 18 Apr 2020 22:20:20 /,
+               '2.HEADER of message/rfc822');
+
+       $ret = $mic->fetch_hash($uidnext, 'BODY[2.MIME]') or
+               BAIL_OUT "2.MIME $@";
+       is($ret->{$uidnext}->{'BODY[2.MIME]'}, <<EOF, 'BODY[2.MIME]');
+Content-Type: message/rfc822\r
+Content-Disposition: attachment; filename="embed2x\.eml"\r
+\r
+EOF
+
+       my @hits = $mic->search('SENTON' => '18-Apr-2020');
+       is_deeply(\@hits, [ $uidnext ], 'search with date condition works');
+       ok($mic->examine($ng), 'EXAMINE on dummy');
+       @hits = $mic->search('SENTSINCE' => '18-Apr-2020');
+       is_deeply(\@hits, [], 'search on dummy with condition works');
+}); # each_inbox
+
+# message sequence numbers :<
+is($mic->Uid(0), 0, 'disable UID on '.ref($mic));
+ok($mic->reconnect, 'reconnected');
+$ret = $mic->fetch_hash('1,2:3', 'RFC822') or BAIL_OUT "FETCH $@";
+is(scalar keys %$ret, 3, 'got all 3 messages with comma-separated sequence');
+$ret = $mic->fetch_hash('1:*', 'RFC822') or BAIL_OUT "FETCH $@";
+is(scalar keys %$ret, 3, 'got all 3 messages');
+{
+       my $rdr = { 0 => \($ret->{1}->{RFC822}) };
+       my $env = { HOME => $ENV{HOME} };
+       my @cmd = qw(-learn rm --all);
+       run_script(\@cmd, $env, $rdr) or BAIL_OUT('-learn rm');
+}
+my $r2 = $mic->fetch_hash('1:*', 'BODY.PEEK[]') or BAIL_OUT "FETCH $@";
+is(scalar keys %$r2, 2, 'did not get all 3 messages');
+is($r2->{1}->{'BODY[]'}, $ret->{2}->{RFC822}, 'message 2 unchanged');
+is($r2->{2}->{'BODY[]'}, $ret->{3}->{RFC822}, 'message 3 unchanged');
+$r2 = $mic->fetch_hash(2, 'BODY.PEEK[HEADER.FIELDS (message-id)]')
+                       or BAIL_OUT "FETCH $@";
+is($r2->{2}->{'BODY[HEADER.FIELDS (MESSAGE-ID)]'},
+       'Message-ID: <20200418222508.GA13918@dcvr>'."\r\n\r\n",
+       'BODY.PEEK[HEADER.FIELDS ...] drops .PEEK');
+
+{
+       my @new_list = $mic->list;
+       # tag differs in [-1]
+       like($orig_list[-1], qr/\A\S+ OK List done\r\n/, 'orig LIST');
+       like($new_list[-1], qr/\A\S+ OK List done\r\n/, 'new LIST');
+       pop @new_list;
+       pop @orig_list;
+       # TODO: not sure if sort order matters, imapd_refresh_finalize
+       # sorts, for now, but maybe clients don't care...
+       is_deeply(\@new_list, \@orig_list, 'LIST identical');
+}
+ok($mic->close, 'CLOSE works');
+ok(!$mic->close, 'CLOSE not idempotent');
+ok($mic->logout, 'logged out');
+
+{
+       my $c = tcp_connect($sock);
+       $c->autoflush(1);
+       like(<$c>, qr/\* OK/, 'got a greeting');
+       print $c "\r\n";
+       like(<$c>, qr/\A\* BAD Error in IMAP command/, 'empty line');
+       print $c "tagonly\r\n";
+       like(<$c>, qr/\Atagonly BAD Error in IMAP command/, 'tag-only line');
+}
 
 $td->kill;
 $td->join;
@@ -300,5 +388,6 @@ is($?, 0, 'no error in exited process');
 open my $fh, '<', $err or BAIL_OUT("open $err failed: $!");
 my $eout = do { local $/; <$fh> };
 unlike($eout, qr/wide/i, 'no Wide character warnings');
+unlike($eout, qr/uninitialized/i, 'no uninitialized warnings');
 
 done_testing;