Email-Abuse-Investigator

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

	ok scalar @unres >= 1, 'domain with no abuse email appears in unresolved list';
	ok scalar(grep { $_->{domain} eq 'nocontact.example' } @unres),
		'nocontact.example in unresolved list';
	restore_net();
};

subtest 'form_contacts -- empty object returns empty list' => sub {
	null_net();
	my $a = Email::Abuse::Investigator->new();
	$a->parse_email("From: x\@y.com\n\nbody");
	$a->{_origin}         = undef;
	$a->{_urls}           = [];
	$a->{_mailto_domains} = [];
	my @forms = $a->form_contacts();
	is scalar @forms, 0, 'empty state: no form contacts';
	restore_net();
};

# =============================================================================
# 38. _raw_whois -- CONTROL CHARACTER INJECTION GUARD
# =============================================================================
# The :Protected _raw_whois method strips all C0/C1 control characters from
# its $query argument before sending it to the WHOIS socket.  These tests
# verify the guard at the method boundary rather than over a live connection.

subtest '_raw_whois -- query of only control characters croaks immediately' => sub {
	# Strategy: a query that is entirely control characters becomes empty
	# after stripping.  The guard must croak before any socket is opened,
	# so this test completes instantly even without a network connection.
	my $a = Email::Abuse::Investigator->new(timeout => 1);
	throws_ok { $a->_raw_whois("\x00\r\n\x01\x1F\x7F", 'localhost') }
		qr/empty query after stripping control characters/i,
		'all-control-character query croaks before any connection attempt';
};

subtest '_raw_whois -- mixed query strips controls and proceeds gracefully' => sub {
	# Strategy: embed NUL and CRLF in an otherwise valid hostname.
	# After stripping the query becomes "example.com", which is valid.
	# The connection to 'localhost' will fail in CI (no WHOIS server there)
	# but the guard must NOT croak; only a clean connection failure returns undef.
	my $a = Email::Abuse::Investigator->new(timeout => 1);
	my $result;
	lives_ok { $result = $a->_raw_whois("exam\x00ple.com\r\n", 'localhost') }
		'embedded NUL/CRLF in query does not croak (control chars stripped)';
	ok !defined($result) || length($result) > 0,
		'returns undef (connection refused) or a real response -- never croaks';
};

subtest '_raw_whois -- lone DEL (0x7F) stripped from query' => sub {
	# DEL is the C1 boundary and must also be stripped.
	my $a = Email::Abuse::Investigator->new(timeout => 1);
	lives_ok { $a->_raw_whois("test\x7Fdomain.example", 'localhost') }
		'DEL character in query does not croak';
};

# =============================================================================
# 39. _rdap_lookup -- IP FORMAT VALIDATION
# =============================================================================
# _rdap_lookup() validates $ip before placing it in the RDAP URL path and
# strips RFC 4007 IPv6 zone identifiers.  These tests exercise the validation
# layer without requiring a live RDAP endpoint.

{
	# Minimal fake UA and response objects: avoid requiring Test::MockObject.
	package t::FakeUA;
	sub new     { bless {}, shift }
	sub get     { bless {}, 't::FakeResponse' }
	package t::FakeResponse;
	sub is_success { 0 }
}

subtest '_rdap_lookup -- slash in IP returns {} without network call' => sub {
	# A path-traversal attempt like '1.2.3.4/../../other' must be caught by
	# the format check (\A\d{1,3}(?:\.\d{1,3}){3}\z fails) and return {}
	# immediately.  We install a fake UA so any accidental network call would
	# produce a visible failure rather than blocking in CI.
	my $a = Email::Abuse::Investigator->new(timeout => 1);
	$a->{ua} = t::FakeUA->new();
	my $r;
	lives_ok { $r = $a->_rdap_lookup('1.2.3.4/../../other') }
		'_rdap_lookup does not croak on slash-containing IP';
	is_deeply $r, {}, 'slash in IP returns empty hashref';
};

subtest '_rdap_lookup -- non-IP string returns {}' => sub {
	my $a = Email::Abuse::Investigator->new(timeout => 1);
	$a->{ua} = t::FakeUA->new();
	my $r;
	lives_ok { $r = $a->_rdap_lookup('not-an-ip') }
		'_rdap_lookup does not croak on non-IP string';
	is_deeply $r, {}, 'non-IP string returns empty hashref';
};

subtest '_rdap_lookup -- IPv6 zone ID stripped; valid IPv6 proceeds to RDAP call' => sub {
	# fe80::1%eth0 has a zone ID.  After stripping %eth0 the remainder
	# fe80::1 matches [0-9a-fA-F:]+ so the method must reach the RDAP call
	# (and return {} because the fake UA returns a 500).  If the zone ID
	# were NOT stripped the format check would reject the value immediately;
	# we can distinguish the two outcomes by checking that the fake UA was
	# called (LWP required) vs returning {} due to validation failure.
	Test::Needs->import('LWP::UserAgent');

	my $a = Email::Abuse::Investigator->new(timeout => 1);

	# Use an object attribute to record calls: named subs in package blocks
	# close over lexicals from compile time, not the runtime invocation of
	# the enclosing subtest, so a shared-lexical spy counter does not work.
	{
		package t::SpyUA;
		sub new     { bless { called => 0 }, shift }
		sub get     { $_[0]->{called} = 1; bless {}, 't::FakeResponse' }
		sub called  { $_[0]->{called} }
	}
	my $spy_ua = t::SpyUA->new();
	$a->{ua} = $spy_ua;

	my $r;
	lives_ok { $r = $a->_rdap_lookup('fe80::1%eth0') }
		'_rdap_lookup does not croak on zone-ID IPv6';
	is_deeply $r, {}, 'returns {} when RDAP endpoint returns failure';
	ok $spy_ua->called(), 'UA was called -- zone ID was stripped and IPv6 passed validation';
};

done_testing();



( run in 2.878 seconds using v1.01-cache-2.11-cpan-9789f410c06 )