public-inbox.git  about / heads / tags
an "archives first" approach to mailing lists
blob ee19f2d765c1ce916b7f16797e08809fedbeb615 12154 bytes (raw)
$ git show HEAD:t/pop3d.t	# shows this blob on the CLI

  1
  2
  3
  4
  5
  6
  7
  8
  9
 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
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
 
#!perl -w
# Copyright (C) all contributors <meta@public-inbox.org>
# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
use v5.12;
use PublicInbox::TestCommon;
use Socket qw(IPPROTO_TCP SOL_SOCKET);
my $cert = 'certs/server-cert.pem';
my $key = 'certs/server-key.pem';
unless (-r $key && -r $cert) {
	plan skip_all =>
		"certs/ missing for $0, run $^X ./create-certs.perl in certs/";
}

# Net::POP3 is part of the standard library, but distros may split it off...
require_mods(qw(DBD::SQLite Net::POP3 IO::Socket::SSL :fcntl_lock));
require_git(v2.6); # for v2
use_ok 'IO::Socket::SSL';
use_ok 'PublicInbox::TLS';
my ($tmpdir, $for_destroy) = tmpdir();
mkdir("$tmpdir/p3state") or xbail "mkdir: $!";
my $err = "$tmpdir/stderr.log";
my $out = "$tmpdir/stdout.log";
my $olderr = "$tmpdir/plain.err";
my $group = 'test-pop3';
my $addr = $group . '@example.com';
my $stls = tcp_server();
my $plain = tcp_server();
my $pop3s = tcp_server();
my $patch = eml_load('t/data/0001.patch');
my $ibx = create_inbox 'pop3d', version => 2, -primary_address => $addr,
			indexlevel => 'basic', sub {
	my ($im, $ibx) = @_;
	$im->add(eml_load('t/plack-qp.eml')) or BAIL_OUT '->add';
	$im->add($patch) or BAIL_OUT '->add';
};
my $pi_config = "$tmpdir/pi_config";
open my $fh, '>', $pi_config or BAIL_OUT "open: $!";
print $fh <<EOF or BAIL_OUT "print: $!";
[publicinbox]
	pop3state = $tmpdir/p3state
[publicinbox "pop3"]
	inboxdir = $ibx->{inboxdir}
	address = $addr
	indexlevel = basic
	newsgroup = $group
EOF
close $fh or BAIL_OUT "close: $!\n";

my $pop3s_addr = tcp_host_port($pop3s);
my $stls_addr = tcp_host_port($stls);
my $plain_addr = tcp_host_port($plain);
my $env = { PI_CONFIG => $pi_config };
my $old = start_script(['-pop3d', '-W0',
	"--stdout=$tmpdir/plain.out", "--stderr=$olderr" ],
	$env, { 3 => $plain });
my @old_args = ($plain->sockhost, Port => $plain->sockport);
my $oldc = Net::POP3->new(@old_args);
my $locked_mb = ('e'x32)."\@$group";
ok($oldc->apop("$locked_mb.0", 'anonymous'), 'APOP to old');

my $dbh = DBI->connect("dbi:SQLite:dbname=$tmpdir/p3state/db.sqlite3",'','', {
	AutoCommit => 1,
	RaiseError => 1,
	PrintError => 0,
	sqlite_use_immediate_transaction => 1,
	sqlite_see_if_its_a_number => 1,
});

{ # locking within the same process
	my $x = Net::POP3->new(@old_args);
	ok(!$x->apop("$locked_mb.0", 'anonymous'), 'APOP lock failure');
	like($x->message, qr/unable to lock/, 'diagnostic message');

	$x = Net::POP3->new(@old_args);
	ok($x->apop($locked_mb, 'anonymous'), 'APOP lock acquire');

	my $y = Net::POP3->new(@old_args);
	ok(!$y->apop($locked_mb, 'anonymous'), 'APOP lock fails once');

	undef $x;
	$y = Net::POP3->new(@old_args);
	ok($y->apop($locked_mb, 'anonymous'), 'APOP lock works after release');
}

for my $args (
	[ "--cert=$cert", "--key=$key",
		"-lpop3s://$pop3s_addr",
		"-lpop3://$stls_addr" ],
) {
	for ($out, $err) { open my $fh, '>', $_ or BAIL_OUT "truncate: $!" }
	my $cmd = [ '-netd', '-W0', @$args, "--stdout=$out", "--stderr=$err" ];
	my $td = start_script($cmd, $env, { 3 => $stls, 4 => $pop3s });

	my %o = (
		SSL_hostname => 'server.local',
		SSL_verifycn_name => 'server.local',
		SSL_verify_mode => SSL_VERIFY_PEER(),
		SSL_ca_file => 'certs/test-ca.pem',
	);
	# start negotiating a slow TLS connection
	my $slow = tcp_connect($pop3s, Blocking => 0);
	$slow = IO::Socket::SSL->start_SSL($slow, SSL_startHandshake => 0, %o);
	my $slow_done = $slow->connect_SSL;
	my @poll;
	if ($slow_done) {
		diag('W: connect_SSL early OK, slow client test invalid');
		use PublicInbox::Syscall qw(EPOLLIN EPOLLOUT);
		@poll = (fileno($slow), EPOLLIN | EPOLLOUT);
	} else {
		@poll = (fileno($slow), PublicInbox::TLS::epollbit());
	}

	my @p3s_args = ($pop3s->sockhost,
			Port => $pop3s->sockport, SSL => 1, %o);
	my $p3s = Net::POP3->new(@p3s_args);
	my $capa = $p3s->capa;
	ok(!exists $capa->{STLS}, 'no STLS CAPA for POP3S');
	ok($p3s->quit, 'QUIT works w/POP3S');
	{
		$p3s = Net::POP3->new(@p3s_args);
		ok(!$p3s->apop("$locked_mb.0", 'anonymous'),
			'APOP lock failure w/ another daemon');
		like($p3s->message, qr/unable to lock/, 'diagnostic message');
	}

	# slow TLS connection did not block the other fast clients while
	# connecting, finish it off:
	until ($slow_done) {
		IO::Poll::_poll(-1, @poll);
		$slow_done = $slow->connect_SSL and last;
		@poll = (fileno($slow), PublicInbox::TLS::epollbit());
	}
	$slow->blocking(1);
	ok(sysread($slow, my $greet, 4096) > 0, 'slow got a greeting');
	my @np3_args = ($stls->sockhost, Port => $stls->sockport);
	my $np3 = Net::POP3->new(@np3_args);
	ok($np3->quit, 'plain QUIT works');
	$np3 = Net::POP3->new(@np3_args, %o);
	$capa = $np3->capa;
	ok(exists $capa->{STLS}, 'STLS CAPA advertised before STLS');
	ok($np3->starttls, 'STLS works');
	$capa = $np3->capa;
	ok(!exists $capa->{STLS}, 'STLS CAPA not advertised after STLS');
	ok($np3->quit, 'QUIT works after STLS');

	for my $mailbox (('x'x32)."\@$group", $group, ('a'x32)."\@z.$group") {
		$np3 = Net::POP3->new(@np3_args);
		ok(!$np3->user($mailbox), "USER $mailbox reject");
		ok($np3->quit, 'QUIT after USER fail');

		$np3 = Net::POP3->new(@np3_args);
		ok(!$np3->apop($mailbox, 'anonymous'), "APOP $mailbox reject");
		ok($np3->quit, "QUIT after APOP fail $mailbox");
	}

	# we do connect+QUIT bumps to try ensuring non-QUIT disconnects
	# get processed below:
	for my $mailbox ($group, "$group.0") {
		my $u = ('f'x32)."\@$mailbox";
		undef $np3;
		ok(Net::POP3->new(@np3_args)->quit, 'connect+QUIT bump');
		$np3 = Net::POP3->new(@np3_args);
		my $n0 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		my $u0 = $dbh->selectrow_array('SELECT COUNT(*) FROM users');
		ok($np3->user($u), "UUID\@$mailbox accept");
		ok($np3->pass('anonymous'), 'pass works');
		my $n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1 - $n0, 1, 'deletes bumped while connected');
		ok($np3->quit, 'client QUIT');

		$n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1, $n0, 'deletes row gone on no-op after QUIT');
		my $u1 = $dbh->selectrow_array('SELECT COUNT(*) FROM users');
		is($u1, $u0, 'users row gone on no-op after QUIT');

		$np3 = Net::POP3->new(@np3_args);
		ok($np3->user($u), "UUID\@$mailbox accept");
		ok($np3->pass('anonymous'), 'pass works');

		my $list = $np3->list;
		my $uidl = $np3->uidl;
		is_deeply([sort keys %$list], [sort keys %$uidl],
			'LIST and UIDL keys match');
		ok($_ > 0, 'bytes in LIST result') for values %$list;
		like($_, qr/\A[a-z0-9]{40,}\z/,
			'blob IDs in UIDL result') for values %$uidl;
		ok($np3->quit, 'QUIT after LIST+UIDL');
		$n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1, $n0, 'deletes row gone on no-op after LIST+UIDL');
		$n0 = $n1;

		$np3 = Net::POP3->new(@np3_args);
		ok($np3->user($u), "UUID\@$mailbox accept");
		ok($np3->pass('anonymous'), 'pass works');
		undef $np3; # QUIT-less disconnect
		ok(Net::POP3->new(@np3_args)->quit, 'connect+QUIT bump');

		$u1 = $dbh->selectrow_array('SELECT COUNT(*) FROM users');
		is($u1, $u0, 'users row gone on QUIT-less disconnect');
		$n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1, $n0, 'deletes row gone on QUIT-less disconnect');
		$n0 = $n1;

		$np3 = Net::POP3->new(@np3_args);
		ok(!$np3->apop($u, 'anonumuss'), 'APOP wrong pass reject');
		$n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1, $n0, 'deletes row not bumped w/ wrong pass');
		undef $np3; # QUIT-less disconnect
		ok(Net::POP3->new(@np3_args)->quit, 'connect+QUIT bump');

		$n1 = $dbh->selectrow_array('SELECT COUNT(*) FROM deletes');
		is($n1, $n0, 'deletes row not bumped w/ wrong pass');

		$np3 = Net::POP3->new(@np3_args);
		ok($np3->apop($u, 'anonymous'), "APOP UUID\@$mailbox");
		my @res = $np3->popstat;
		is($res[0], 2, 'STAT knows about 2 messages');

		my $msg = $np3->get(2);
		$msg = join('', @$msg);
		$msg =~ s/\r\n/\n/g;
		is_deeply(PublicInbox::Eml->new($msg), $patch,
			't/data/0001.patch round-tripped');

		ok(!$np3->get(22), 'missing message');

		$msg = $np3->top(2, 0);
		$msg = join('', @$msg);
		$msg =~ s/\r\n/\n/g;
		is($msg, $patch->header_obj->as_string . "\n",
			'TOP numlines=0');

		ok(!$np3->top(2, -1), 'negative TOP numlines');

		$msg = $np3->top(2, 1);
		$msg = join('', @$msg);
		$msg =~ s/\r\n/\n/g;
		is($msg, $patch->header_obj->as_string . <<EOF,

Filenames within a project tend to be reasonably stable within a
EOF
			'TOP numlines=1');

		$msg = $np3->top(2, 10000);
		$msg = join('', @$msg);
		$msg =~ s/\r\n/\n/g;
		is_deeply(PublicInbox::Eml->new($msg), $patch,
			'TOP numlines=10000 (excess)');

		$np3 = Net::POP3->new(@np3_args, %o);
		ok($np3->starttls, 'STLS works before APOP');
		ok($np3->apop($u, 'anonymous'), "APOP UUID\@$mailbox w/ STLS");

		# undocumented:
		ok($np3->_NOOP, 'NOOP works') if $np3->can('_NOOP');
	}

	SKIP: {
		skip 'TCP_DEFER_ACCEPT is Linux-only', 2 if $^O ne 'linux';
		my $var = eval { Socket::TCP_DEFER_ACCEPT() } // 9;
		my $x = getsockopt($pop3s, IPPROTO_TCP, $var) //
			xbail "IPPROTO_TCP: $!";
		ok(unpack('i', $x) > 0, 'TCP_DEFER_ACCEPT set on POP3S');
		$x = getsockopt($stls, IPPROTO_TCP, $var) //
			xbail "IPPROTO_TCP: $!";
		is(unpack('i', $x), 0, 'TCP_DEFER_ACCEPT is 0 on plain POP3');
	};
	SKIP: {
		require_mods '+accf_data';
		require PublicInbox::Daemon;
		my $x = getsockopt($pop3s, SOL_SOCKET,
				$PublicInbox::Daemon::SO_ACCEPTFILTER);
		like($x, qr/\Adataready\0+\z/, 'got dataready accf for pop3s');
		$x = getsockopt($stls, IPPROTO_TCP,
				$PublicInbox::Daemon::SO_ACCEPTFILTER);
		is($x, undef, 'no BSD accept filter for plain POP3');
	};

	$td->kill;
	$td->join;
	is($?, 0, 'no error in exited -netd');
	open my $fh, '<', $err or BAIL_OUT "open $err failed: $!";
	my $eout = do { local $/; <$fh> };
	unlike($eout, qr/wide/i, 'no Wide character warnings in -netd');
}

{
	my $capa = $oldc->capa;
	ok(defined($capa->{PIPELINING}), 'pipelining supported by CAPA');
	is($capa->{EXPIRE}, 0, 'EXPIRE 0 set');
	ok(!exists $capa->{STLS}, 'STLS unset w/o daemon certs');

	# ensure TOP doesn't trigger "EXPIRE 0" like RETR does (cf. RFC2449)
	my $list = $oldc->list;
	ok(scalar keys %$list, 'got a listing of messages');
	ok($oldc->top($_, 1), "TOP $_ 1") for keys %$list;
	ok($oldc->quit, 'QUIT after TOP');

	# clients which see "EXPIRE 0" can elide DELE requests
	$oldc = Net::POP3->new(@old_args);
	ok($oldc->apop("$locked_mb.0", 'anonymous'), 'APOP for RETR');
	is_deeply($oldc->capa, $capa, 'CAPA unchanged');
	is_deeply($oldc->list, $list, 'LIST unchanged by previous TOP');
	ok($oldc->get($_), "RETR $_") for keys %$list;
	ok($oldc->quit, 'QUIT after RETR');

	$oldc = Net::POP3->new(@old_args);
	ok($oldc->apop("$locked_mb.0", 'anonymous'), 'APOP reconnect');
	my $cont = $oldc->list;
	is_deeply($cont, {}, 'no messages after implicit DELE from EXPIRE 0');
	ok($oldc->quit, 'QUIT on noop');

	# test w/o checking CAPA to trigger EXPIRE 0
	$oldc = Net::POP3->new(@old_args);
	ok($oldc->apop($locked_mb, 'anonymous'), 'APOP on latest slice');
	my $l2 = $oldc->list;
	is_deeply($l2, $list, 'different mailbox, different deletes');
	ok($oldc->get($_), "RETR $_") for keys %$list;
	ok($oldc->quit, 'QUIT w/o EXPIRE nor DELE');

	$oldc = Net::POP3->new(@old_args);
	ok($oldc->apop($locked_mb, 'anonymous'), 'APOP again on latest');
	$l2 = $oldc->list;
	is_deeply($l2, $list, 'no DELE nor EXPIRE preserves messages');
	ok($oldc->delete(2), 'explicit DELE on latest');
	ok($oldc->quit, 'QUIT w/ highest DELE');

	# this is non-standard behavior, but necessary if we expect hundreds
	# of thousands of users on cheap HW
	$oldc = Net::POP3->new(@old_args);
	ok($oldc->apop($locked_mb, 'anonymous'), 'APOP yet again on latest');
	is_deeply($oldc->list, {}, 'highest DELE deletes older messages, too');
}

# TODO: more tests, but mpop was really helpful in helping me
# figure out bugs with larger newsgroups (>50K messages) which
# probably isn't suited for this test suite.

$old->kill;
$old->join;
is($?, 0, 'no error in exited -pop3d');
open $fh, '<', $olderr or BAIL_OUT "open $olderr failed: $!";
my $eout = do { local $/; <$fh> };
unlike($eout, qr/wide/i, 'no Wide character warnings in -pop3d');

done_testing;

git clone https://public-inbox.org/public-inbox.git
git clone http://7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/public-inbox.git