App-Project-Doctor

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

	is( $h->{line}, 99, 'line key present with correct value' );
};

subtest 'Finding::icon -- returns bracketed string for all four valid severities' => sub {
	Readonly::Hash my %EXPECT => (
		$SEV_ERROR   => '[X]',
		$SEV_WARNING => '[!]',
		$SEV_PASS    => '[v]',
		$SEV_INFO    => '[i]',
	);
	for my $sev (keys %EXPECT) {
		my $f = _f(severity => $sev);
		is( $f->icon, $EXPECT{$sev}, "icon for $sev is $EXPECT{$sev}" );
	}
};

# ===========================================================================
# Context -- hostile inputs and path traversal
# ===========================================================================

subtest 'Context::new -- non-existent root croaks' => sub {
	throws_ok { $Context->new(root => '/no/such/path/xyz987abc') }
		qr/is not a directory/,
		'non-existent root rejected';
};

subtest 'Context::new -- plain file path as root croaks' => sub {
	# A file exists but is not a directory.
	my $tmp = File::Temp->new(UNLINK => 1);
	throws_ok { $Context->new(root => $tmp->filename) }
		qr/is not a directory/,
		'file path rejected as root';
};

subtest 'Context::has_file -- undef rel_path croaks' => sub {
	throws_ok { _ctx()->has_file(undef) }
		qr/requires a relative path/,
		'has_file(undef) croaks with documented error';
};

subtest 'Context::abs_path -- undef rel_path croaks' => sub {
	throws_ok { _ctx()->abs_path(undef) }
		qr/requires a relative path/,
		'abs_path(undef) croaks';
};

subtest 'Context::slurp -- undef rel_path croaks' => sub {
	throws_ok { _ctx()->slurp(undef) }
		qr/requires a relative path/,
		'slurp(undef) croaks';
};

subtest 'Context::slurp -- missing file croaks with documented error' => sub {
	throws_ok { _ctx()->slurp('no_such_file.pm') }
		qr/File not found/,
		'slurp of absent file croaks "File not found"';
};

# abs_path must reject paths with ".." components to prevent
# reading/checking files outside the distribution root.
subtest 'Context -- path traversal via abs_path is blocked' => sub {
	my $ctx = _ctx();
	throws_ok { $ctx->abs_path('../outside.txt') }
		qr/path traversal/i,
		'abs_path with leading ".." rejected';
	throws_ok { $ctx->abs_path('lib/../../outside.txt') }
		qr/path traversal/i,
		'abs_path with embedded ".." rejected';
};

subtest 'Context -- path traversal via has_file is blocked' => sub {
	my $ctx = _ctx();
	throws_ok { $ctx->has_file('../sibling') }
		qr/path traversal/i,
		'has_file with ".." component rejected';
};

subtest 'Context -- path traversal via slurp is blocked' => sub {
	my $ctx = _ctx();
	throws_ok { $ctx->slurp('../secret.txt') }
		qr/path traversal/i,
		'slurp with ".." component rejected';
};

subtest 'Context::perl_files -- non-existent dirs skipped silently' => sub {
	# An empty root with no lib/ or t/ must return an empty arrayref, not die.
	my $dir = tempdir(CLEANUP => 1);
	my $ctx = $Context->new(root => $dir);
	my $result;
	lives_ok { $result = $ctx->perl_files('lib', 't', 'nonexistent') }
		'missing dirs do not cause perl_files to die';
	returns_ok( $result, { type => 'arrayref' }, 'still returns arrayref' );
	is( scalar @{$result}, 0, 'empty arrayref for missing dirs' );
};

subtest 'Context::perl_files -- does not clobber outer $_' => sub {
	# File::Find localises $_ internally; we verify the outer value survives.
	my $ctx = _ctx(_distro('lib/A.pm' => '1;'));
	local $_ = 'sentinel_value';
	$ctx->perl_files;
	is( $_, 'sentinel_value', '$_ unchanged by perl_files' );
};

subtest 'Context::find_files -- undef dir argument croaks' => sub {
	throws_ok { _ctx()->find_files(undef) }
		qr/requires a directory/,
		'find_files(undef) croaks';
};

# ===========================================================================
# Report -- hostile add_findings inputs and empty-state rendering
# ===========================================================================

subtest 'Report::add_findings -- undef finding croaks' => sub {
	throws_ok { $Report->new->add_findings(undef) }
		qr/Expected an App::Project::Doctor::Finding/,
		'undef rejected by add_findings';
};

subtest 'Report::add_findings -- plain string croaks' => sub {
	throws_ok { $Report->new->add_findings('not a finding') }
		qr/Expected an App::Project::Doctor::Finding/,
		'string rejected by add_findings';
};

subtest 'Report::add_findings -- wrong blessed class croaks' => sub {
	# A blessed hashref that is not an ::Finding must be rejected.
	my $impostor = bless { severity => $SEV_ERROR, message => 'fake' }, 'FakeClass';
	throws_ok { $Report->new->add_findings($impostor) }
		qr/Expected an App::Project::Doctor::Finding/,
		'wrong-class object rejected';
};

subtest 'Report::add_findings -- no-arg call is a harmless no-op' => sub {
	my $r = $Report->new;
	lives_ok { $r->add_findings() } 'no-arg add_findings does not die';
	is( scalar($r->all_findings), 0, 'report stays empty' );
};

t/edge_cases.t  view on Meta::CPAN

};

subtest 'Fixer -- STDIN out-of-range index applies no fixes' => sub {
	my $applied = 0;
	my $r       = _report_with(_f(fix => sub { $applied++ }));
	my $out     = $EMPTY;
	my @w;
	my $count = _run_fixer_interactive($r, "99999\n", \$out, \@w);
	is( $count,   0, 'out-of-range index applies 0 fixes' );
	is( $applied, 0, 'fix coderef not called for index > max' );
};

subtest 'Fixer -- duplicate indices apply each fix exactly once' => sub {
	# Before the fix: "1,1,1" would call fix #1 three times.
	# After the fix: indices are deduplicated; fix #1 is called once.
	my $call_count = 0;
	my $r          = _report_with(_f(fix => sub { $call_count++ }));
	my $out        = $EMPTY;
	my @w;
	my $count = _run_fixer_interactive($r, "1,1,1\n", \$out, \@w);
	is( $call_count, 1, 'duplicate "1,1,1" applies fix exactly once (deduped)' );
	is( $count,      1, 'run returns 1 successful fix'                          );
};

subtest 'Fixer -- all-whitespace STDIN input applies no fixes' => sub {
	# "   " matches ^[\d,\s]+$ but splits to an empty list of indices.
	my $applied = 0;
	my $r       = _report_with(_f(fix => sub { $applied++ }));
	my $out     = $EMPTY;
	my @w;
	my $count = _run_fixer_interactive($r, "   \n", \$out, \@w);
	is( $applied, 0, 'whitespace-only index list applies no fixes' );
};

subtest 'Fixer -- non_interactive mode applies all fixes without STDIN' => sub {
	my @applied;
	my $r = $Report->new;
	for my $i (1 .. 3) {
		$r->add_findings(_f(fix => sub { push @applied, $i }));
	}
	my $fixer = $Fixer->new(report => $r, context => _ctx(), non_interactive => 1);
	my $out;
	open(local *STDOUT, '>', \$out) or die;
	my $count = $fixer->run;
	is( $count, 3, 'non_interactive mode applies all 3 fixes' );
	is( scalar @applied, 3, 'all 3 fix coderefs were called' );
};

# ===========================================================================
# Doctor -- code injection via check name and root detection
# ===========================================================================

subtest 'Doctor::run -- no root marker found croaks' => sub {
	# A directory without Makefile.PL / Build.PL / dist.ini / cpanfile.
	my $dir = tempdir(CLEANUP => 1);
	throws_ok { $Doctor->new(path => $dir)->run }
		qr/Cannot detect a distribution root/,
		'run croaks when no root marker is present';
};

subtest 'Doctor -- check name injection blocked before eval' => sub {
	# Before the fix: eval "require App::Project::Doctor::Check::Tests;
	# ++$main::INJECT_SENTINEL; 1" would execute the increment.
	# After the fix: names not matching /\A[A-Za-z][A-Za-z0-9]*\z/ are
	# rejected with a carp and skipped before the eval runs.
	local $INJECT_SENTINEL = 0;

	my $dir = _distro('Makefile.PL' => '');
	my $doctor = $Doctor->new(
		path   => $dir,
		checks => ['Tests; ++$main::INJECT_SENTINEL; 1'],
	);
	my ($out, @carped);
	{
		open(local *STDOUT, '>', \$out) or die;
		local $SIG{__WARN__} = sub { push @carped, $_[0] };
		eval { $doctor->run };
	}

	is( $INJECT_SENTINEL, 0, 'injected code was not executed' );
	ok( scalar @carped, 'invalid check name produces a carp warning' );
	like( join($EMPTY, @carped), qr/invalid|character/i,
		'warning message mentions invalid characters' );
	diag "carp: @carped" if $ENV{TEST_VERBOSE};
};

subtest 'Doctor -- check name with path separators is sanitised' => sub {
	# "../../etc/passwd" must be rejected before reaching eval.
	local $INJECT_SENTINEL = 0;

	my $dir = _distro('Makefile.PL' => '');
	my $doctor = $Doctor->new(
		path   => $dir,
		checks => ['../../etc/passwd'],
	);
	my ($out, @carped);
	{
		open(local *STDOUT, '>', \$out) or die;
		local $SIG{__WARN__} = sub { push @carped, $_[0] };
		eval { $doctor->run };
	}
	ok( 1, 'path-separator check name did not crash Doctor' );
	ok( scalar @carped, 'path-separator check name produces a carp' );
};

subtest 'Doctor::run -- preserves caller $@ across a failing check' => sub {
	# Doctor::run wraps check invocations in eval{}; caller $@ must survive.
	my $dir = _distro('Makefile.PL' => '');
	$@ = 'caller_sentinel';

	my $doctor = $Doctor->new(path => $dir, checks => ['Tests']);
	my ($out, @carped);
	{
		open(local *STDOUT, '>', \$out) or die;
		local $SIG{__WARN__} = sub { push @carped, $_[0] };
		my $g = mock_scoped 'App::Project::Doctor::Check::Tests::check'
			=> sub { die "deliberate failure\n" };
		$doctor->run;    # run catches the die internally; does not itself die
	}
	restore_all();



( run in 1.765 second using v1.01-cache-2.11-cpan-bbe5e583499 )