CGI-Info

 view release on metacpan or  search on metacpan

t/function.t  view on Meta::CPAN

use Scalar::Util qw(blessed);
use Test::Mockingbird 0.08 qw(mock mock_scoped mock_return spy restore_all);

# CGI::Info must load cleanly before any mocking
BEGIN { use_ok('CGI::Info') or BAIL_OUT('CGI::Info failed to load') }

# Silence Log::Abstraction's _high_priority stderr for the entire run.
# WAF blocks and validation failures generate expected log output that
# would otherwise pollute harness output; we assert on return values.
mock 'Log::Abstraction::_high_priority' => sub { };

# ============================================================
# Configuration constants -- no magic literals anywhere below
# ============================================================
Readonly my $UPLOAD_SMALL      => 100;
Readonly my $UPLOAD_MAX        => 2048;
Readonly my $UPLOAD_OVERSIZED  => 999_999_999;
Readonly my $GOOD_IP           => '1.2.3.4';
Readonly my $GOOGLEBOT_IP      => '66.249.66.1';
Readonly my $PORT_HTTP         => 80;
Readonly my $PORT_HTTPS        => 443;
Readonly my $STATUS_OK         => 200;
Readonly my $STATUS_FORBIDDEN  => 403;
Readonly my $STATUS_NOT_FOUND  => 404;
Readonly my $STATUS_METHOD_NA  => 405;
Readonly my $STATUS_LENGTH_REQ => 411;
Readonly my $STATUS_TOO_LARGE  => 413;
Readonly my $STATUS_UNPROC     => 422;
Readonly my $UA_IPHONE  => 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)';
Readonly my $UA_ANDROID => 'Mozilla/5.0 (Linux; Android 10; Pixel 3)';
Readonly my $UA_IPAD    => 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)';
Readonly my $UA_DESKTOP => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120';
Readonly my $UA_GBOT         => 'Googlebot/2.1 (+http://www.google.com/bot.html)';
Readonly my $UA_CBOT         => 'ClaudeBot/1.0';
Readonly my $UA_CLAUDE_WEB   => 'Claude-Web/1.0';
Readonly my $UA_GPTBOT       => 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)';
Readonly my $UA_CHATGPT_USER => 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); ChatGPT-User/1.0; +https://openai.com/bot)';
Readonly my $UA_COHERE_AI    => 'cohere-ai/1.0';
# GPTBot UA that also embeds a SQL injection payload: used to verify WAF ordering
Readonly my $UA_GPTBOT_SQL   => 'GPTBot/1.0 SELECT foo AND bar FROM baz';

# %config gathers all constants for Object::Configure-style flexibility
my %config = (
	upload_small      => $UPLOAD_SMALL,
	upload_max        => $UPLOAD_MAX,
	upload_oversized  => $UPLOAD_OVERSIZED,
	good_ip           => $GOOD_IP,
	googlebot_ip      => $GOOGLEBOT_IP,
	port_http         => $PORT_HTTP,
	port_https        => $PORT_HTTPS,
	status_ok         => $STATUS_OK,
	status_forbidden  => $STATUS_FORBIDDEN,
	status_not_found  => $STATUS_NOT_FOUND,
	status_method_na  => $STATUS_METHOD_NA,
	status_length_req => $STATUS_LENGTH_REQ,
	status_too_large  => $STATUS_TOO_LARGE,
	status_unproc     => $STATUS_UNPROC,
	ua_iphone         => $UA_IPHONE,
	ua_android        => $UA_ANDROID,
	ua_ipad           => $UA_IPAD,
	ua_desktop        => $UA_DESKTOP,
	ua_gbot           => $UA_GBOT,
	ua_cbot           => $UA_CBOT,
	ua_claude_web     => $UA_CLAUDE_WEB,
	ua_gptbot         => $UA_GPTBOT,
	ua_chatgpt_user   => $UA_CHATGPT_USER,
	ua_cohere_ai      => $UA_COHERE_AI,
	ua_gptbot_sql     => $UA_GPTBOT_SQL,
);

# ---------------------------------------------------------------------------
# Helper: wipe CGI environment variables and class state between subtests
# ---------------------------------------------------------------------------
sub reset_env {
	delete $ENV{$_} for qw(
		GATEWAY_INTERFACE REQUEST_METHOD QUERY_STRING CONTENT_TYPE
		CONTENT_LENGTH SCRIPT_NAME SCRIPT_FILENAME DOCUMENT_ROOT
		C_DOCUMENT_ROOT HTTP_HOST SERVER_NAME SSL_TLS_SNI SERVER_PROTOCOL
		SERVER_PORT SCRIPT_URI REMOTE_ADDR HTTP_USER_AGENT HTTP_COOKIE
		HTTP_X_WAP_PROFILE HTTP_SEC_CH_UA_MOBILE HTTP_REFERER IS_MOBILE
		IS_SEARCH_ENGINE IS_AI LOGDIR
	);
	CGI::Info->reset();
}

# ============================================================
# 1. new()
# ============================================================

# Basic object construction
subtest 'new() - basic instantiation returns blessed object' => sub {
	plan tests => 2;
	reset_env();
	my $info = CGI::Info->new();
	ok(blessed($info), 'new() returns a blessed reference');
	isa_ok($info, 'CGI::Info');
};

# Hashref argument style
subtest 'new() - hashref args set internal fields' => sub {
	plan tests => 1;
	reset_env();
	my $info = CGI::Info->new({ max_upload_size => $config{upload_small} });
	is($info->{max_upload_size}, $config{upload_small}, 'max_upload_size set via hashref');
};

# Flat hash argument style
subtest 'new() - flat hash args set internal fields' => sub {
	plan tests => 1;
	reset_env();
	my $info = CGI::Info->new(max_upload_size => $config{upload_max});
	is($info->{max_upload_size}, $config{upload_max}, 'max_upload_size set via flat hash');
};

# Clone path merges args over parent, parent unchanged
subtest 'new() - clone overrides field without modifying parent' => sub {
	plan tests => 2;
	reset_env();
	my $orig  = CGI::Info->new(max_upload_size => 999);
	my $clone = $orig->new(max_upload_size => 111);
	is($clone->{max_upload_size}, 111, 'clone has overridden field');

t/function.t  view on Meta::CPAN


# SERVER_PORT 443 boundary -- exact value, one below, one above
subtest 'protocol() - SERVER_PORT 443 boundary' => sub {
	plan tests => 3;
	reset_env();
	my $guard = mock_scoped 'Socket::getservbyport' => sub { return undef };

	$ENV{SERVER_PORT} = $config{port_https};
	is(CGI::Info->new()->protocol(), 'https', 'port 443 => https');

	$ENV{SERVER_PORT} = $config{port_https} - 1;
	my $p = CGI::Info->new()->protocol();
	ok(!defined($p) || $p ne 'https', 'port 442 does not return https');

	$ENV{SERVER_PORT} = $config{port_https} + 1;
	$p = CGI::Info->new()->protocol();
	ok(!defined($p) || $p ne 'https', 'port 444 does not return https');
};

# SERVER_PORT 80 boundary
subtest 'protocol() - SERVER_PORT 80 boundary' => sub {
	plan tests => 3;
	reset_env();
	my $guard = mock_scoped 'Socket::getservbyport' => sub { return undef };

	$ENV{SERVER_PORT} = $config{port_http};
	is(CGI::Info->new()->protocol(), 'http', 'port 80 => http');

	$ENV{SERVER_PORT} = $config{port_http} - 1;
	my $p = CGI::Info->new()->protocol();
	ok(!defined($p) || $p ne 'http', 'port 79 does not return http');

	$ENV{SERVER_PORT} = $config{port_http} + 1;
	$p = CGI::Info->new()->protocol();
	ok(!defined($p) || $p ne 'http', 'port 81 does not return http');
};

# ============================================================
# 10. is_mobile()
# ============================================================

# iPhone UA detected as mobile
subtest 'is_mobile() - iPhone user-agent' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_iphone};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(CGI::Info->new()->is_mobile(), 'iPhone UA detected as mobile');
};

# Android UA detected as mobile
subtest 'is_mobile() - Android user-agent' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_android};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(CGI::Info->new()->is_mobile(), 'Android UA detected as mobile');
};

# Desktop UA not detected as mobile
subtest 'is_mobile() - desktop user-agent not mobile' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_desktop};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(!CGI::Info->new()->is_mobile(), 'desktop UA not detected as mobile');
};

# Sec-CH-UA-Mobile: ?1 header
subtest 'is_mobile() - Sec-CH-UA-Mobile hint' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_SEC_CH_UA_MOBILE} = '?1';
	ok(CGI::Info->new()->is_mobile(), 'Sec-CH-UA-Mobile ?1 detected as mobile');
};

# HTTP_X_WAP_PROFILE signals a mobile device
subtest 'is_mobile() - WAP profile header' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_X_WAP_PROFILE} = 'http://wap.example.com/profile';
	ok(CGI::Info->new()->is_mobile(), 'HTTP_X_WAP_PROFILE signals mobile');
};

# IS_MOBILE env override
subtest 'is_mobile() - IS_MOBILE env override' => sub {
	plan tests => 1;
	reset_env();
	$ENV{IS_MOBILE} = 1;
	ok(CGI::Info->new()->is_mobile(), 'IS_MOBILE env override works');
};

# ============================================================
# 11. is_tablet()
# ============================================================

subtest 'is_tablet() - iPad user-agent' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_ipad};
	ok(CGI::Info->new()->is_tablet(), 'iPad UA detected as tablet');
};

subtest 'is_tablet() - desktop user-agent not a tablet' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = 'Mozilla/5.0 (Windows NT 10.0)';
	ok(!CGI::Info->new()->is_tablet(), 'desktop UA not detected as tablet');
};

# ============================================================
# 12. is_robot()
# ============================================================

# Googlebot may be classed as robot or search engine, both are correct
subtest 'is_robot() - Googlebot classed as robot or search engine' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_gbot};
	$ENV{REMOTE_ADDR}     = $config{googlebot_ip};
	my $info   = CGI::Info->new();
	my $result = $info->is_robot() || $info->is_search_engine();
	ok($result, 'Googlebot classified as robot or search engine');
};

# ClaudeBot is a known robot
subtest 'is_robot() - ClaudeBot detected' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(CGI::Info->new()->is_robot(), 'ClaudeBot detected as robot');
};

# SQL injection in UA sets 403 and marks as robot
subtest 'is_robot() - SQL injection in UA sets 403' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = 'Mozilla SELECT foo AND bar FROM baz';
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_robot(), 'SQL injection UA flagged as robot');
	is($info->status(), $config{status_forbidden}, 'status 403 on SQL injection UA');
};

# No CGI environment means assume real person (return 0)
subtest 'is_robot() - no CGI env returns 0' => sub {
	plan tests => 1;
	reset_env();
	is(CGI::Info->new()->is_robot(), 0, 'no CGI env returns 0 (assumes real person)');
};

# Critical security invariant: the SQL injection WAF check runs BEFORE is_ai(),
# so an AI crawler UA that also contains an injection payload still receives 403.
# If this order were reversed, the AI check would short-circuit and skip the WAF.
subtest 'is_robot() - SQL injection in AI crawler UA triggers 403 (ordering invariant)' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_gptbot_sql};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_robot(), 'injection in AI UA => is_robot still true');
	is($info->status(), $config{status_forbidden},
		'injection in AI UA => HTTP 403 set (WAF not bypassed by AI classification)');
};

# ============================================================
# 13. is_search_engine()
# ============================================================

subtest 'is_search_engine() - IS_SEARCH_ENGINE env override' => sub {
	plan tests => 1;
	reset_env();
	$ENV{IS_SEARCH_ENGINE} = 1;
	$ENV{REMOTE_ADDR}      = $config{good_ip};
	$ENV{HTTP_USER_AGENT}  = 'SomeBot';
	ok(CGI::Info->new()->is_search_engine(), 'IS_SEARCH_ENGINE env override works');
};

subtest 'is_search_engine() - no CGI env returns 0' => sub {
	plan tests => 1;
	reset_env();
	is(CGI::Info->new()->is_search_engine(), 0, 'no CGI env returns 0');
};

# ============================================================
# 14. is_ai()
# ============================================================

# Known AI training crawler => is_ai true and browser_type 'ai'
subtest 'is_ai() - ClaudeBot detected as AI crawler' => sub {
	plan tests => 3;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_ai(),                     'ClaudeBot => is_ai true');
	ok($info->is_robot(),                  'ClaudeBot => is_robot true (invariant)');
	is($info->browser_type(), 'ai',        'ClaudeBot => browser_type is ai');
};

# UA with no "bot"/"spider" token must still satisfy is_ai AND is_robot
subtest 'is_ai() - ChatGPT-User (no bot token) satisfies invariant' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); ChatGPT-User/1.0; +https://openai.com/bot)';
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_ai(),    'ChatGPT-User => is_ai true');
	ok($info->is_robot(), 'ChatGPT-User => is_robot true (is_robot calls is_ai internally)');
};

# IS_AI env override (use local so the override cannot bleed into later tests)
subtest 'is_ai() - IS_AI env override' => sub {
	plan tests => 1;
	reset_env();
	local $ENV{IS_AI}     = 1;
	$ENV{HTTP_USER_AGENT} = $config{ua_desktop};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(CGI::Info->new()->is_ai(), 'IS_AI=1 env override forces is_ai true');
};

# Non-AI UA must not trigger is_ai
subtest 'is_ai() - desktop UA is not AI' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_desktop};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(!CGI::Info->new()->is_ai(), 'desktop UA is not detected as AI crawler');
};

# No CGI environment => 0
subtest 'is_ai() - no CGI env returns 0' => sub {
	plan tests => 1;
	reset_env();
	is(CGI::Info->new()->is_ai(), 0, 'no CGI env returns 0');
};

# Call-order invariant: is_robot() first, then is_ai()
subtest 'is_ai() - call order: is_robot first still gives is_ai true' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_claude_web};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_robot(), 'Claude-Web: is_robot() true when called first');
	ok($info->is_ai(),    'Claude-Web: is_ai() true after is_robot()');
};

# IS_AI=0 must force false even when the UA matches the AI pattern.
# The env override is authoritative in both directions.
subtest 'is_ai() - IS_AI=0 override forces false for known AI UA' => sub {
	plan tests => 1;
	reset_env();
	local $ENV{IS_AI}     = 0;
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	ok(!CGI::Info->new()->is_ai(), 'IS_AI=0 env override suppresses AI detection');
};

# Without REMOTE_ADDR the CGI environment is incomplete; method returns 0
# without caching, consistent with is_robot() and is_search_engine() behaviour.
subtest 'is_ai() - no REMOTE_ADDR returns 0' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	# REMOTE_ADDR deliberately absent
	ok(!CGI::Info->new()->is_ai(), 'absent REMOTE_ADDR => is_ai returns 0');
};

# GPTBot (OpenAI training crawler): representative OpenAI-family UA.
# Confirms that is_ai does not depend on "ClaudeBot" alone.
subtest 'is_ai() - GPTBot detected as AI crawler' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_gptbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_ai(),    'GPTBot => is_ai true');
	ok($info->is_robot(), 'GPTBot => is_robot true (invariant holds)');
};

# cohere-ai contains no "bot" or "spider" token; this exercises the regex
# branches that cover unusual AI crawler UA string formats.
subtest 'is_ai() - cohere-ai UA (no bot/spider token) satisfies invariant' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cohere_ai};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_ai(),    'cohere-ai => is_ai true');
	ok($info->is_robot(), 'cohere-ai => is_robot true (no "bot" token -- tests invariant path)');
};

# Googlebot is a search engine robot but NOT an AI training crawler;
# is_ai() must return false for it even though is_robot() returns true.
subtest 'is_ai() - Googlebot is robot but not AI' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_gbot};
	$ENV{REMOTE_ADDR}     = $config{googlebot_ip};
	my $info = CGI::Info->new();
	ok(!$info->is_ai(), 'Googlebot => is_ai false');
	ok($info->is_robot() || $info->is_search_engine(),
		'Googlebot => still classified as robot or search engine');
};

# The first call computes and caches $self->{is_ai}; the second call must
# return the same result from the cache without re-evaluating the UA regex.
subtest 'is_ai() - result is cached within the same instance' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info   = CGI::Info->new();
	my $first  = $info->is_ai();
	my $second = $info->is_ai();	# must hit $self->{is_ai} cache
	ok($first,             'first call => is_ai true');
	is($second, $first,    'second call => identical cached result');
};

# Call-order: is_ai() first sets $self->{is_robot}=1; is_robot() then hits
# the instance cache and returns 1 without re-running its own detection logic.
subtest 'is_ai() - call order: is_ai first populates is_robot cache' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_claude_web};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	my $info = CGI::Info->new();
	ok($info->is_ai(),    'Claude-Web: is_ai() true when called first');
	ok($info->is_robot(), 'Claude-Web: is_robot() true after is_ai() (cache set by is_ai)');
};

# ============================================================
# 15. browser_type()
# ============================================================

subtest 'browser_type() - mobile' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_iphone};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	is(CGI::Info->new()->browser_type(), 'mobile', 'iPhone browser_type is mobile');
};

subtest 'browser_type() - desktop is web' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_desktop};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	is(CGI::Info->new()->browser_type(), 'web', 'desktop browser_type is web');
};

# AI crawlers must be classified as 'ai', which is checked before 'robot'.
# Verifies the priority order: mobile > ai > search > robot > web.
subtest 'browser_type() - returns ai for known AI crawler' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = $config{ua_cbot};
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	is(CGI::Info->new()->browser_type(), 'ai', 'ClaudeBot => browser_type is ai');
};

# A generic spider that is NOT in the AI list must still return 'robot',
# not 'ai', confirming the AI check does not over-reach.
subtest 'browser_type() - generic spider returns robot not ai' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_USER_AGENT} = 'SomeGenericSpider/1.0';
	$ENV{REMOTE_ADDR}     = $config{good_ip};
	is(CGI::Info->new()->browser_type(), 'robot', 'generic spider => browser_type is robot');
};

# ============================================================
# 15. cookie() / get_cookie()
# ============================================================

# cookie() returns value for known cookie name
subtest 'cookie() - returns value for known cookie' => sub {
	plan tests => 2;
	reset_env();
	$ENV{HTTP_COOKIE} = 'session=abc123; user=bob';
	my $info = CGI::Info->new();
	is($info->cookie('session'), 'abc123', 'cookie() returns session value');
	is($info->cookie('user'),    'bob',    'cookie() returns user value');
};

# Missing cookie returns undef
subtest 'cookie() - returns undef for missing cookie' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_COOKIE} = 'a=1';
	ok(!defined CGI::Info->new()->cookie('nosuchcookie'), 'missing cookie returns undef');
};

# get_cookie() is an alias for cookie()
subtest 'get_cookie() - named-arg alias for cookie()' => sub {
	plan tests => 1;
	reset_env();
	$ENV{HTTP_COOKIE} = 'token=xyz';
	my $info = CGI::Info->new();
	is($info->get_cookie(cookie_name => 'token'), 'xyz', 'get_cookie() named-arg alias works');
};

# No HTTP_COOKIE means undef
subtest 'cookie() - no HTTP_COOKIE returns undef' => sub {
	plan tests => 1;
	reset_env();
	ok(!defined CGI::Info->new()->cookie('x'), 'no HTTP_COOKIE env returns undef');
};

t/function.t  view on Meta::CPAN

	# Object::Configure has injected a logger; _error must not croak
	lives_ok { $info->_error('logged error') }
		'_error does not croak when a logger is present';
};

# ============================================================
# 30. POST with application/json content type
# ============================================================

subtest 'params() - POST JSON body decoded to hash' => sub {
	plan tests => 1;
	reset_env();
	my $json_body = '{"alpha":"one","beta":"two"}';
	$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
	$ENV{REQUEST_METHOD}    = 'POST';
	$ENV{CONTENT_TYPE}      = 'application/json';
	$ENV{CONTENT_LENGTH}    = length($json_body);
	$CGI::Info::stdin_data  = $json_body;

	my $info = CGI::Info->new();
	my $p    = $info->params();
	if (defined $p) {
		is($p->{alpha}, 'one', 'JSON POST: alpha=one');
	} else {
		pass('JSON POST: no result (JSON module unavailable, acceptable)');
	}
};

# ============================================================
# 31. POST with text/xml content type
# ============================================================

subtest 'params() - POST XML body stored under XML key' => sub {
	plan tests => 2;
	reset_env();
	my $xml_body = '<root><item>test</item></root>';
	$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
	$ENV{REQUEST_METHOD}    = 'POST';
	$ENV{CONTENT_TYPE}      = 'text/xml';
	$ENV{CONTENT_LENGTH}    = length($xml_body);
	$CGI::Info::stdin_data  = $xml_body;

	my $info = CGI::Info->new();
	my $p    = $info->params();
	ok(defined $p,          'XML POST returns a defined hashref');
	is($p->{XML}, $xml_body, 'XML body stored under key "XML"');
};

# ============================================================
# 32. $_ preservation (internal helpers must not clobber $_)
# Any internal use of for/foreach without a lexical variable could
# overwrite $_ in the caller's scope.
# ============================================================

subtest '$_ not clobbered by public methods' => sub {
	plan tests => 6;
	reset_env();
	$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
	$ENV{REQUEST_METHOD}    = 'GET';
	$ENV{QUERY_STRING}      = 'x=1';
	$ENV{HTTP_USER_AGENT}   = $config{ua_desktop};
	$ENV{REMOTE_ADDR}       = $config{good_ip};
	$ENV{HTTP_HOST}         = 'example.com';

	my $info = CGI::Info->new();

	# Set $_ to a sentinel and verify each method leaves it unchanged
	local $_ = 'SENTINEL';

	$info->params();
	is($_, 'SENTINEL', 'params() does not clobber $_');

	$info->is_mobile();
	is($_, 'SENTINEL', 'is_mobile() does not clobber $_');

	$info->is_robot();
	is($_, 'SENTINEL', 'is_robot() does not clobber $_');

	$info->as_string();
	is($_, 'SENTINEL', 'as_string() does not clobber $_');

	$info->script_name();
	is($_, 'SENTINEL', 'script_name() does not clobber $_');

	$info->host_name();
	is($_, 'SENTINEL', 'host_name() does not clobber $_');
};

# Internal helpers must also preserve $_
subtest '$_ not clobbered by internal helpers' => sub {
	plan tests => 3;
	reset_env();
	my $info = CGI::Info->new();

	local $_ = 'SENTINEL2';

	$info->_log('info', 'msg');
	is($_, 'SENTINEL2', '_log does not clobber $_');

	$info->_debug('dbg');
	is($_, 'SENTINEL2', '_debug does not clobber $_');

	CGI::Info::_sanitise_input('hello world');
	is($_, 'SENTINEL2', '_sanitise_input does not clobber $_');
};

# ============================================================
# 33. Test::Memory::Cycle -- object must be cycle-free
# ============================================================

subtest 'CGI::Info object has no circular references' => sub {
	plan tests => 1;
	reset_env();
	$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
	$ENV{REQUEST_METHOD}    = 'GET';
	$ENV{QUERY_STRING}      = 'a=1&b=2';
	$ENV{HTTP_USER_AGENT}   = $config{ua_iphone};
	$ENV{REMOTE_ADDR}       = $config{good_ip};
	$ENV{HTTP_HOST}         = 'example.com';

	my $info = CGI::Info->new();



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