Database-BI

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

};

# ---------------------------------------------------------------------------
# Section 8: Empty CSV file opened via /open
# ---------------------------------------------------------------------------

subtest 'GET /open -- 0-byte CSV file renders without crashing' => sub {
	my ($fh, $fname) = tempfile(SUFFIX => '.csv', DIR => $TMPDIR);
	close $fh;  # 0 bytes written
	$t->get_ok('/open?path=' . url_escape($fname))
	  ->status_is(200, '200 (no crash for empty CSV)');
};

subtest 'GET /open -- headers-only CSV renders with 0 data rows' => sub {
	my $path = Mojo::File->new($TMPDIR)->child('headers_only.csv');
	$path->spew("id,name,amount\n");  # header line only
	$t->get_ok('/open?path=' . url_escape($path->to_string))
	  ->status_is(200)
	  ->content_unlike(qr/500 Internal Server Error/, 'no 500 error for headers-only file');
};

# ---------------------------------------------------------------------------
# Section 9: Mocked upstream failures -- selectall_arrayref croak is caught gracefully
#
# Strategy: use Test::Mockingbird to replace DataSource::selectall_arrayref with
# a croaking stub.  Verify the eval in the view action catches the croak and
# renders the friendly error template (200, not 500).
# (The controller calls count() then selectall_arrayref() since pagination was
# introduced; fetch_all() is no longer called on the /view or /open paths.)
# ---------------------------------------------------------------------------

subtest 'GET /view/sales -- fetch_all croak is caught, friendly error rendered' => sub {
	mock 'Database::BI::Model::DataSource::selectall_arrayref' => sub {
		die "Simulated upstream database failure\n"
	};

	$t->get_ok('/view/sales')
	  ->status_is(200, 'controller returns 200 (error page), not 500')
	  ->content_like(qr/Could not open table/i,
	      'error_table_open message appears in error page');

	restore_all();
};

subtest 'GET /open -- fetch_all croak is caught, friendly error rendered' => sub {
	my $path = Mojo::File->new($TMPDIR)->child('sales.csv');
	$path->spew("id,name\n1,Widget\n");

	mock 'Database::BI::Model::DataSource::selectall_arrayref' => sub {
		die "Simulated read failure\n"
	};

	$t->get_ok('/open?path=' . url_escape($path->to_string))
	  ->status_is(200)
	  ->content_like(qr/Could not open/i, 'error_file_open message in error page');

	restore_all();
};

# ---------------------------------------------------------------------------
# Section 10: SSRF via GET /import endpoint -- HTTP-level verification
#
# For blocked IPs the controller short-circuits before any LWP call, so
# these are fast and deterministic (no network required).
# ---------------------------------------------------------------------------

subtest 'GET /import -- private IP in URL triggers SSRF error response' => sub {
	for my $ip ('127.0.0.1', '10.0.0.1', '192.168.1.1', '172.16.0.1', '169.254.169.254', '100.64.0.0') {
		$t->get_ok("/import?url=" . url_escape("http://$ip/"))
		  ->status_is(200)
		  ->content_like(qr/private or reserved address/i,
		      "$ip: SSRF error shown");
	}
};

subtest 'GET /import -- localhost by name triggers SSRF error' => sub {
	$t->get_ok('/import?url=' . url_escape('http://localhost/'))
	  ->status_is(200)
	  ->content_like(qr/private or reserved address/i, 'localhost SSRF error shown');
};

subtest 'GET /import -- missing URL triggers required-field error' => sub {
	$t->get_ok('/import')
	  ->status_is(200)
	  ->content_like(qr/Please enter a URL/i, 'empty url error shown');
};

subtest 'GET /import -- non-http:// URL triggers invalid-URL error' => sub {
	$t->get_ok('/import?url=' . url_escape('ftp://example.com/data.csv'))
	  ->status_is(200)
	  ->content_like(qr/not a valid http/i, 'invalid scheme error shown');
};

# ---------------------------------------------------------------------------
# Section 11: Filter via HTTP -- malformed f= params do not break the view
# ---------------------------------------------------------------------------

subtest 'GET /view/sales?f= -- empty filter spec is harmless' => sub {
	$t->get_ok('/view/sales?f=')
	  ->status_is(200)
	  ->content_unlike(qr/500 Internal Server Error/, 'no 500 for empty f=');
};

subtest 'GET /view/sales?f=:eq:val -- empty column name is harmless' => sub {
	$t->get_ok('/view/sales?f=' . url_escape(':eq:val'))
	  ->status_is(200)
	  ->content_unlike(qr/500 Internal Server Error/, 'no 500 for missing column in f=');
};

subtest 'GET /view/sales?f=col:badop:val -- unknown operator is harmless' => sub {
	$t->get_ok('/view/sales?f=' . url_escape('region:badop:North'))
	  ->status_is(200)
	  ->content_unlike(qr/500 Internal Server Error/, 'no 500 for unknown operator');
};

subtest 'GET /view/sales -- many f= params are handled without crash' => sub {
	my $url = '/view/sales?' . join('&', map { 'f=' . url_escape("region:eq:North$_") } 1 .. 50);
	$t->get_ok($url)
	  ->status_is(200)
	  ->content_unlike(qr/500 Internal Server Error/, 'no 500 for 50 filter conditions');
};

t/edge_cases.t  view on Meta::CPAN

# A file with a blank first line is effectively empty: _detect_file_info
# returns the _file_is_empty sentinel (same path as a 0-byte file), so
# _init_backend skips Database::Abstraction entirely and fetch_all returns [].
# ---------------------------------------------------------------------------

# 0-byte CSV via DataSource constructor
subtest 'DataSource -- 0-byte CSV: fetch_all returns [] without croak' => sub {
	require Database::BI::Model::DataSource;
	my $dir = tempdir(CLEANUP => 1);
	Mojo::File->new("$dir/emptysrc.csv")->spew('');
	my $src;
	lives_ok {
		$src = Database::BI::Model::DataSource->new(directory => $dir, table => 'emptysrc');
	} '0-byte CSV: constructor succeeds';
	my $rows = $src->fetch_all;
	is_deeply $rows, [], '0-byte CSV: fetch_all returns []';
};

# 0-byte PSV via DataSource constructor
subtest 'DataSource -- 0-byte PSV: fetch_all returns [] without croak' => sub {
	require Database::BI::Model::DataSource;
	my $dir = tempdir(CLEANUP => 1);
	Mojo::File->new("$dir/emptypsv.psv")->spew('');
	my $src;
	lives_ok {
		$src = Database::BI::Model::DataSource->new(directory => $dir, table => 'emptypsv');
	} '0-byte PSV: constructor succeeds';
	my $rows = $src->fetch_all;
	is_deeply $rows, [], '0-byte PSV: fetch_all returns []';
};

# Newline-only CSV via DataSource constructor
subtest 'DataSource -- newline-only CSV: fetch_all returns [] without croak' => sub {
	# A file containing exactly "\n" has a blank first line.  After chomp the
	# line is "", yielding zero column names.  This must be treated as an empty
	# file (returning []), not as "no safe identifier" (which would croak).
	require Database::BI::Model::DataSource;
	my $dir = tempdir(CLEANUP => 1);
	Mojo::File->new("$dir/newlinecsv.csv")->spew("\n");
	my $src;
	lives_ok {
		$src = Database::BI::Model::DataSource->new(directory => $dir, table => 'newlinecsv');
	} 'newline-only CSV: constructor succeeds (no croak)';
	my $rows = $src->fetch_all;
	is_deeply $rows, [], 'newline-only CSV: fetch_all returns []';
};

# Newline-only PSV via DataSource constructor
subtest 'DataSource -- newline-only PSV: fetch_all returns [] without croak' => sub {
	require Database::BI::Model::DataSource;
	my $dir = tempdir(CLEANUP => 1);
	Mojo::File->new("$dir/newlinepsv.psv")->spew("\n");
	my $src;
	lives_ok {
		$src = Database::BI::Model::DataSource->new(directory => $dir, table => 'newlinepsv');
	} 'newline-only PSV: constructor succeeds (no croak)';
	my $rows = $src->fetch_all;
	is_deeply $rows, [], 'newline-only PSV: fetch_all returns []';
};

# 0-byte CSV via HTTP /open endpoint
subtest 'HTTP -- 0-byte CSV via /open: 200 with empty-table message' => sub {
	my $dir  = tempdir(CLEANUP => 1);
	my $path = "$dir/emptyhttp.csv";
	Mojo::File->new($path)->spew('');
	$t->get_ok('/open?path=' . url_escape($path))
	  ->status_is(200, '0-byte CSV: /open returns 200')
	  ->content_like(qr/No records found/i, '0-byte CSV: empty-state message present');
};

# 0-byte PSV via HTTP /open endpoint
subtest 'HTTP -- 0-byte PSV via /open: 200 with empty-table message' => sub {
	my $dir  = tempdir(CLEANUP => 1);
	my $path = "$dir/emptyhttppsv.psv";
	Mojo::File->new($path)->spew('');
	$t->get_ok('/open?path=' . url_escape($path))
	  ->status_is(200, '0-byte PSV: /open returns 200')
	  ->content_like(qr/No records found/i, '0-byte PSV: empty-state message present');
};

# Newline-only CSV via HTTP /open endpoint
subtest 'HTTP -- newline-only CSV via /open: 200 with empty-table message' => sub {
	# Regression guard: before the _file_is_empty fix, a blank first line caused
	# _init_backend to croak "no safe identifier", and the controller rendered a
	# 200 error page — but the error message was confusing for what is simply an
	# empty file.  After the fix, the response is a clean empty-table view.
	my $dir  = tempdir(CLEANUP => 1);
	my $path = "$dir/nlcsv.csv";
	Mojo::File->new($path)->spew("\n");
	$t->get_ok('/open?path=' . url_escape($path))
	  ->status_is(200, 'newline-only CSV: /open returns 200')
	  ->content_like(qr/No records found/i, 'newline-only CSV: empty-state message present');
};

# Newline-only PSV via HTTP /open endpoint
subtest 'HTTP -- newline-only PSV via /open: 200 with empty-table message' => sub {
	my $dir  = tempdir(CLEANUP => 1);
	my $path = "$dir/nlpsv.psv";
	Mojo::File->new($path)->spew("\n");
	$t->get_ok('/open?path=' . url_escape($path))
	  ->status_is(200, 'newline-only PSV: /open returns 200')
	  ->content_like(qr/No records found/i, 'newline-only PSV: empty-state message present');
};

# ---------------------------------------------------------------------------
# Section 16: _safe_back_url -- XSS / scheme injection guard
#
# Strategy: Verify that the _safe_back_url helper (added to fix the reflected
# XSS where javascript: URIs passed through | html unchanged and executed on
# click) correctly allows safe URLs and blocks hostile schemes.
# ---------------------------------------------------------------------------

Readonly my $SAFE_BACK_URL => \&Database::BI::Controller::Dashboard::_safe_back_url;

subtest '_safe_back_url -- javascript: scheme is blocked' => sub {
	is $SAFE_BACK_URL->('javascript:alert(1)'),     undef, 'bare javascript: blocked';
	is $SAFE_BACK_URL->('javascript:void(0)'),      undef, 'javascript:void(0) blocked';
	is $SAFE_BACK_URL->('JAVASCRIPT:alert(1)'),     undef, 'JAVASCRIPT: (caps) blocked';
	is $SAFE_BACK_URL->('Javascript:alert(1)'),     undef, 'Javascript: (mixed) blocked';
};

subtest '_safe_back_url -- data: and vbscript: schemes are blocked' => sub {
	is $SAFE_BACK_URL->('data:text/html,<h1>XSS'),  undef, 'data: scheme blocked';
	is $SAFE_BACK_URL->('vbscript:MsgBox(1)'),      undef, 'vbscript: scheme blocked';
	is $SAFE_BACK_URL->('DATA:text/html,foo'),       undef, 'DATA: (caps) blocked';
};

subtest '_safe_back_url -- relative paths are allowed' => sub {
	is $SAFE_BACK_URL->('/view/sales'),              '/view/sales',     'root-relative path allowed';
	is $SAFE_BACK_URL->('/pie?l=table:sales'),       '/pie?l=table:sales', 'root-relative with query allowed';
	is $SAFE_BACK_URL->('/open?path=/tmp/f.csv'),    '/open?path=/tmp/f.csv', 'root-relative open path allowed';
};

subtest '_safe_back_url -- http/https absolute URLs are allowed' => sub {
	is $SAFE_BACK_URL->('https://example.com/path'), 'https://example.com/path', 'https: allowed';
	is $SAFE_BACK_URL->('http://example.com/'),      'http://example.com/',      'http: allowed';
	is $SAFE_BACK_URL->('HTTPS://EXAMPLE.COM/'),     'HTTPS://EXAMPLE.COM/',     'HTTPS (caps) allowed';
};

subtest '_safe_back_url -- undef, empty, and non-path strings are blocked' => sub {
	is $SAFE_BACK_URL->(undef),   undef, 'undef blocked';
	is $SAFE_BACK_URL->(''),      undef, 'empty string blocked';
	is $SAFE_BACK_URL->('  '),    undef, 'whitespace-only blocked';
	is $SAFE_BACK_URL->('alert(1)'), undef, 'bare JS expression blocked';
	is $SAFE_BACK_URL->('//evil.com'), undef, 'protocol-relative URL blocked';
};

# ---------------------------------------------------------------------------
# Section 17: HTTP back= and back2= XSS reflected in the rendered page
#
# Strategy: Send requests with javascript: payloads in back= (pie, graph) and
# back2= (dashboard) params.  Verify the rendered HTML does NOT contain the
# raw javascript: string as an href value -- it must be sanitized away
# (rendered as "/" fallback or entirely absent from the breadcrumb).
# ---------------------------------------------------------------------------



( run in 1.158 second using v1.01-cache-2.11-cpan-16aabcbfbcb )