Discord-Client
view release on metacpan or search on metacpan
t/04-rest.t view on Meta::CPAN
use v5.26;
use warnings;
use Test2::V1;
use JSON::PP;
use HTTP::Response;
use lib 't/lib';
use TestDiscordREST qw(client response error);
T2->subtest(
'JSON requests use API v10, Bot authorization and verified HTTPS',
sub {
my ( $rest, $http ) = client();
T2->is( $http->count, 0, 'construction performs no HTTP I/O' );
# Send Unicode through the fake transport and inspect the actual request envelope.
my $future = $rest->request( 'POST', '/channels/123/messages', json => { content => "hello \x{2603}" } );
T2->isa_ok( $future, 'Future' );
my $transport = $http->call;
my $request = $transport->{request};
T2->is( $request->uri, 'https://discord.com/api/v10/channels/123/messages', 'the URL selects API v10' );
T2->is( $transport->{host}, 'discord.com', 'the transport receives the Discord hostname' );
T2->ok( $transport->{SSL}, 'HTTPS is explicitly enabled' );
T2->is( $request->header('Authorization'), 'Bot FAKE_TEST_TOKEN', 'the request uses Bot authorization' );
my $version = $Discord::Client::VERSION;
T2->like(
$request->header('User-Agent'),
qr/^DiscordBot \(https:\/\/.+, \Q$version\E\)$/,
'User-Agent identifies the SDK version'
);
T2->is( $request->content_type, 'application/json', 'JSON content type is sent' );
T2->is(
JSON::PP->new->utf8->decode( $request->content ),
{ content => "hello \x{2603}" },
'non-ASCII content is transmitted as UTF-8 JSON'
);
T2->is( $transport->{max_redirects}, 0, 'credentials cannot follow a redirect' );
# Return a uint64-sized string ID and unknown fields without narrowing either.
my $message = { id => '18446744073709551615', extra => [ 1, 2 ] };
$http->respond( response( 200, $message ) );
T2->is( scalar( $future->get ), $message, 'large snowflakes and unknown fields survive decoding' );
$rest->close;
}
);
T2->subtest(
'array-valued query parameters are repeated and array responses stay arrays',
sub {
my ( $rest, $http ) = client();
# Exercise repeated query values and preserve the response collection shape.
my $future =
$rest->request( 'GET', '/users/@me', query => { id => [ '123', '456' ], limit => 2 } );
T2->is( $http->call->{request}->uri->query,
'id=123&id=456&limit=2', 'each query value is encoded separately' );
$http->respond( response( 200, [ 1, 2 ] ) );
T2->is( scalar( $future->get ), [ 1, 2 ], 'JSON array responses are preserved' );
$rest->close;
}
);
T2->subtest(
'HTTP 204 and HEAD do not require JSON bodies',
sub {
my ( $rest, $http ) = client();
# A successful deletion has no JSON document to decode.
my $deletion = $rest->request( 'DELETE', '/channels/123/messages/456' );
$http->respond( response( 204, undef ) );
T2->is( scalar( $deletion->get ), undef, 'HTTP 204 resolves without JSON' );
# HEAD also omits the body even when the status is 200.
my $head = $rest->request( 'HEAD', '/users/@me' );
$http->respond( HTTP::Response->new(200) );
T2->is( scalar( $head->get ), undef, 'HEAD resolves without JSON' );
$rest->close;
}
);
T2->subtest(
'raw responses expose headers without retaining credential-bearing requests',
sub {
my ( $rest, $http ) = client();
my $future = $rest->request( 'GET', '/users/@me', raw_response => 1 );
# Attach a credential-bearing request to the response as a real transport might.
my $original = response( 200, { id => '12' }, 'X-Example' => 'value' );
$original->request( $http->call->{request} );
$http->respond($original);
# Expose detached response metadata without retaining or mutating the source request.
my $raw = $future->get;
T2->isa_ok( $raw, 'HTTP::Response' );
T2->is( $raw->header('X-Example'), 'value', 'server headers are available' );
T2->is( $raw->request, undef, 'the authorization-bearing request is detached' );
t/04-rest.t view on Meta::CPAN
);
for my $case (@http_failures) {
T2->subtest(
"$case->{name} preserves actionable HTTP/Discord details",
sub {
my ( $rest, $http ) = client();
my $future = $rest->request( 'POST', '/channels/123/messages', json => { content => 'test' } );
$http->respond(
response(
$case->{status},
{ code => 50035, message => 'failure', errors => { content => 'invalid' } },
'X-Request-ID' => 'req1'
)
);
T2->ok( $future->is_failed, 'the HTTP rejection fails the request Future' );
my $error = error($future);
T2->isa_ok( $error, 'Discord::Client::REST::Error' );
T2->is( $error->category, $case->{category}, 'failure category' );
T2->is( $error->status, $case->{status}, 'HTTP status' );
T2->is( $error->discord_code, 50035, 'Discord error code' );
T2->is( $error->request_id, 'req1', 'server request identifier' );
T2->is( $error->details->{errors}, { content => 'invalid' }, 'structured validation detail' );
$rest->close;
}
);
}
T2->subtest(
'malformed successful JSON fails without exposing the response body',
sub {
my ( $rest, $http ) = client();
# Place a token in malformed content to check the parser failure cannot disclose it.
my $future = $rest->request( 'GET', '/users/@me' );
$http->respond( HTTP::Response->new( 200, undef, [], '{broken FAKE_TEST_TOKEN' ) );
T2->is( error($future)->category, 'json', 'a successful status still needs valid JSON' );
T2->unlike( join( ' ', $future->failure ),
qr/FAKE_TEST_TOKEN/, 'parser diagnostics omit response content' );
$rest->close;
}
);
T2->subtest(
'non-JSON server errors retain the HTTP failure category',
sub {
my ( $rest, $http ) = client();
# A server error page must keep its HTTP category even though it is not JSON.
my $future = $rest->request( 'GET', '/users/@me' );
$http->respond( HTTP::Response->new( 503, undef, [], '<html>unavailable</html>' ) );
T2->is( error($future)->category, 'server', 'HTTP failure takes precedence over JSON decoding' );
T2->is( error($future)->status, 503, 'the server status remains available' );
$rest->close;
}
);
T2->subtest(
'failure details redact Bot, webhook and nested authorization credentials',
sub {
my ( $rest, $http ) = client();
# Simulate credentials echoed in message text, named fields and nested structures.
my $future = $rest->request( 'POST', '/webhooks/123/WEBHOOK_SECRET', json => {} );
$http->respond(
response(
400,
{
code => 50035,
message => 'FAKE_TEST_TOKEN WEBHOOK_SECRET',
token => 'other secret',
nested => [ { authorization => 'other secret' } ]
}
)
);
T2->unlike(
JSON::PP->new->encode( error($future)->details ),
qr/FAKE_TEST_TOKEN|WEBHOOK_SECRET|other secret/,
'structured errors omit all credentials'
);
T2->unlike(
join( ' ', $future->failure ),
qr/SECRET|FAKE_TEST_TOKEN/,
'failure strings are also sanitized'
);
$rest->close;
}
);
# Reject paths that could change the intended API destination before calling HTTP.
my @invalid_paths = (
{ name => 'absolute URL', path => 'https://evil.test/a' },
{ name => 'scheme-relative URL', path => '//evil.test/a' },
{ name => 'parent directory traversal', path => '/a/../b' },
{ name => 'control character', path => "/a\n" },
{ name => 'URL fragment', path => '/a#frag' },
);
for my $case (@invalid_paths) {
T2->subtest(
"reject $case->{name} before sending HTTP",
sub {
my ( $rest, $http ) = client();
my $future = $rest->request( 'GET', $case->{path} );
T2->is( error($future)->category, 'request', 'invalid paths produce a failed Future' );
T2->is( $http->count, 0, 'the transport was not called' );
$rest->close;
}
);
}
T2->subtest(
'unencodable bodies fail before sending HTTP',
sub {
my ( $rest, $http ) = client();
# Serialization failure is local and must not consume a request slot.
my $future = $rest->request( 'GET', '/users/@me', json => sub { } );
T2->is( error($future)->category, 'request', 'invalid JSON bodies produce a failed Future' );
T2->is( $http->count, 0, 'the transport was not called' );
$rest->close;
}
);
T2->done_testing;
( run in 1.444 second using v1.01-cache-2.11-cpan-3f962061493 )