# Unit tests for POE::Filter::Tor::TC1 module # -*- CPerl -*- use strict; use warnings; use if $ENV{AUTOMATED_TESTING}, Carp => 'verbose'; use Storable qw(dclone); use Test::More tests => 2 # loading tests + 9 # reply validation helper tests + 18*2# reply parser helper tests (later repeated with full filter) + 1 # bogus input test + 8 # pending buffer tests + 2; # exception test with invalid state BEGIN { use_ok('POE::Filter::Tor::TC1') or BAIL_OUT("POE::Filter::Tor::TC1 failed to load") } BEGIN { my $fail = 0; eval q{use POE::Filter::Tor::TC1 9999.999; $fail = 1}; ok($fail == 0 && $@ =~ m/POE::Filter::Tor::TC1.* version 9999.*required--this is only/, 'POE::Filter::Tor::TC1 version check') } # ---------------------------------------- BEGIN { *_check_reply = \&POE::Filter::Tor::TC1::_check_reply } sub test_check_reply { my @result = _check_reply(shift); my $expected_flags = shift; shift @result; # discard input array reference is_deeply(\@result, $expected_flags, shift) or explain \@result } test_check_reply ['250 OK'], [1], 'validate simple response'; test_check_reply ['250+data follows:', 'blob blob blob', '.', '250 OK'], [1], 'validate simple data block response'; test_check_reply ['250-line 1', '250-line 2', '250 line 3'], [1], 'validate multi-line response with common status code'; test_check_reply ['250-item 1 ok', '550-item 2 not ok', '250-item 3 ok', '550 item 4 not ok'], [0], 'validate multi-line response with mixed status codes'; subtest 'reject response without initial status code' => sub { plan tests => 2; my $fail = 0; eval {test_check_reply ['bogus bogus bogus', '.', '250 OK'], [], ''; $fail = 1}; ok(!$fail, 'validating bogus record throws exception'); like($@, qr/invalid first line:/, 'validating record without initial status throws expected exception'); }; subtest 'reject incomplete response' => sub { plan tests => 2; my $fail = 0; eval {test_check_reply ['250-BEGIN', '250-MORE'], [], ''; $fail = 1}; ok(!$fail, 'validating bogus record throws exception'); like($@, qr/incomplete response/, 'validating record with incomplete response throws expected exception'); }; subtest 'reject multiple responses in same call' => sub { plan tests => 2; my $fail = 0; eval {test_check_reply ['250-OK', '250 OK', '250-OK', '250 OK'], [], ''; $fail = 1}; ok(!$fail, 'validating bogus record throws exception'); like($@, qr/returned to idle state/, 'validating record with multiple responses throws expected exception'); }; subtest 'reject mixed async/sync response' => sub { plan tests => 2; my $fail = 0; eval {test_check_reply ['650-OK', '250-OK', '650-OK', '250 OK'], [], ''; $fail = 1}; ok(!$fail, 'validating bogus record throws exception'); like($@, qr/async\/sync responses conflated:/, 'validating record with async/sync mix throws expected exception'); }; subtest 'reject mixed sync/async response' => sub { plan tests => 2; my $fail = 0; eval {test_check_reply ['250-OK', '650-OK', '250-OK', '650 OK'], [], ''; $fail = 1}; ok(!$fail, 'validating bogus record throws exception'); like($@, qr/async\/sync responses conflated:/, 'validating record with sync/async mix throws expected exception'); }; # ---------------------------------------- BEGIN { *_parse_reply = \&POE::Filter::Tor::TC1::_parse_reply } # some sample responses taken from the Tor control protocol spec my @REPLY_PARSE_TESTS = ([['250 OK'] => [250, 'OK'], 'simple response'], [['451 Resource exhausted'] => [451, 'Resource exhausted'], 'simple error response'], [['650 CIRC 1000 EXTENDED moria1,moria2'] => [650, CIRC => 1000, EXTENDED => 'moria1,moria2'], 'sample circuit extension event'], [['650-CIRC 1000 EXTENDED moria1,moria2 0xBEEF', '650-EXTRAMAGIC=99', '650 ANONYMITY=high'] => [650, CIRC => 1000, EXTENDED => 'moria1,moria2', '0xBEEF', {EXTRAMAGIC => 99, ANONYMITY => 'high'}], 'sample extended circuit extension event'], [['650 ADDRMAP ddg.gg 52.149.246.39 "2021-02-11 20:34:04"' .' EXPIRES="2021-02-12 02:34:04" CACHED="NO"'] => [650, ADDRMAP => 'ddg.gg', '52.149.246.39', '2021-02-11 20:34:04', {EXPIRES => '2021-02-12 02:34:04', CACHED => 'NO'}], 'sample ADDRMAP event with quoted strings'], [['650 ADDRMAP ddg.gg 52.149.246.39 "2021-02\\\\-11\\" \\"20\\\\:34:04"' .' EXPIRES="2021\\\\-02-\\"12 02\\":34\\\\\\:04" CACHED="NO"'] => [650, ADDRMAP => 'ddg.gg', '52.149.246.39', '2021-02\\-11" "20\\:34:04', {EXPIRES => '2021\\-02-"12 02":34\\:04', CACHED => 'NO'}], 'sample ADDRMAP event with quoted strings and escape handling'], [['250 SafeLogging=0'] => [250, {SafeLogging => 0}], 'sample GETCONF response (one item)'], [['250 SocksPort'] => [250, 'SocksPort'], 'sample GETCONF response (one item; default)'], [['250-Log=notice stdout', '250 SafeLogging=0'] => [250, {Log => 'notice stdout', SafeLogging => 0}], 'sample GETCONF response (two items)'], [['250-Log=notice stdout', '250-SocksPort', '250-NATDPort', '250 SafeLogging=0'] => [250, 'SocksPort', 'NATDPort', {Log => 'notice stdout', SafeLogging => 0}], 'sample GETCONF response (four items; two default)'], [['250-OldAddress1=NewAddress1', '250 OldAddress2=NewAddress2'] => [250, {OldAddress1 => 'NewAddress1', OldAddress2 => 'NewAddress2'}], 'sample MAPADDRESS response with common reply code'], [['512-syntax error: invalid address \'@@@\'', '250 127.199.80.246=bogus1.google.com'] => [undef, [512, 'syntax error: invalid address \'@@@\''], [250, '127.199.80.246=bogus1.google.com']], 'sample MAPADDRESS response with varying reply code'], [['250-PROTOCOLINFO 1', '250-AUTH METHODS=NULL', '250-VERSION Tor="0.2.0.5-alpha"', '250 OK'], => [250, PROTOCOLINFO => 1, 'OK', {AUTH => { METHODS => 'NULL' }, VERSION => { Tor => '0.2.0.5-alpha'}}], 'sample PROTOCOLINFO response'], [['250-PROTOCOLINFO 1', '250-AUTH METHODS=NULL', '250-VERSION Tor="0.\\\\2\\".0\\\\\\\\.\\"5-alpha\\\\"', '250 OK'], => [250, PROTOCOLINFO => 1, 'OK', {AUTH => { METHODS => 'NULL' }, VERSION => { Tor => '0.\\2".0\\\\."5-alpha\\'}}], 'sample PROTOCOLINFO response with escape handling'], [['250-ServiceID=testboguskeydata', '250-PrivateKey=RSA1024:base64/key/data/bits=', '250 OK'] => [250, 'OK', {ServiceID => 'testboguskeydata', PrivateKey => 'RSA1024:base64/key/data/bits='}], 'sample ADD_ONION response'], [['650+NS', 'blob blob blob', 'blob of blob', 'blob blob blob', '.', '650 OK'] => [650, 'NS', <<__BLOB__, 'OK'], blob blob blob blob of blob blob blob blob __BLOB__ 'async blob response'], [['250+desc/name/moria=', 'moria descriptor line 1', 'moria descriptor line 2', '.', '250-version=Tor 0.1.1.0-alpha-cvs', '250 OK'], => [250, 'OK', {version => 'Tor 0.1.1.0-alpha-cvs', 'desc/name/moria' => <<__BLOB__}], moria descriptor line 1 moria descriptor line 2 __BLOB__ 'sample GETINFO response with blob'], [['512-invalid name \'@@@\'', '250+desc/name/moria=', 'moria descriptor line 1', 'moria descriptor line 2', '.', '250-info/item/1=info item 1', '250 OK'] => [undef, [512, 'invalid name \'@@@\''], [250, 'desc/name/moria='.<<__BLOB__], moria descriptor line 1 moria descriptor line 2 __BLOB__ [250, 'info/item/1=info item 1'], [250, 'OK']], 'sample mixed status code response with blob'], ); foreach my $test (@REPLY_PARSE_TESTS) { my $parsed = _parse_reply(_check_reply(dclone($test->[0]))); is_deeply($parsed, $test->[1], $test->[2]) or diag explain $parsed; } # ---------------------------------------- subtest 'bogus input' => sub { plan tests => 3; my $fail = 0; my $filter = POE::Filter::Tor::TC1->new; $filter->get_one_start(["bogus bogus", " bogus\015\012", "250 OK\015\012"]); my $result; eval {$result = $filter->get_one; $fail = 1}; ok(!defined $result, 'no result from reading bogus record') or diag explain $result; ok(!$fail, 'reading bogus record throws exception'); like($@, qr/invalid response received:/, 'reading bogus record at idle throws expected exception'); }; # ---------------------------------------- { my $filter = POE::Filter::Tor::TC1->new; my $result = $filter->get_pending; is($result, undef, 'buffer is initially empty') or diag explain $result; $filter->get_one_start(["250 OK\015\012"]); $result = $filter->get_one; is_deeply($result, [[250, 'OK']], 'simple response through filter') or diag explain $result; $filter->get_one_start(["250-line 1\015\012"]); $result = $filter->get_one; is_deeply($result, [], 'empty result with partial response buffered (1)') or diag explain $result; $result = $filter->get_pending; is_deeply($result, ["250-line 1\015\012"], 'buffer contains initial line') or diag explain $result; $filter->get_one_start(["250"]); $result = $filter->get_one; is_deeply($result, [], 'empty result with partial response buffered (2)') or diag explain $result; $result = $filter->get_pending; is_deeply($result, ["250-line 1\015\012", "250"], 'buffer contains partial response') or diag explain $result; $filter->get_one_start([" OK\015\012"]); $result = $filter->get_one; is_deeply($result, [[250, 'line', '1', 'OK']], 'sample response parsed when complete') or diag explain $result; $result = $filter->get_pending; is($result, undef, 'buffer empty after reading complete response') or diag explain $result; } # ---------------------------------------- { my $filter = POE::Filter::Tor::TC1->new; foreach my $test (@REPLY_PARSE_TESTS) { my $parsed = $filter->get([map {$_."\015\012"} @{$test->[0]}]); is_deeply($parsed, [$test->[1]], 'filtered '.$test->[2]) or diag explain $parsed; } # intentionally corrupt the filter state to test last edge case $filter->[POE::Filter::Tor::TC1::LINE_STATE] = 42; my $fail = 0; my $result; eval {$result = $filter->get(["bogus bogus bogus\015\012"]); $fail = 1}; ok(!$fail, 'exception thrown due to invalid state'); like($@, qr/invalid internal state 42/, 'expected exception thrown due to invalid state'); }