Developer-Dashboard

 view release on metacpan or  search on metacpan

t/08-web-update-coverage.t  view on Meta::CPAN

#!/bin/sh
cat <<'EOF'

Ethernet adapter Ethernet:

   IPv4 Address. . . . . . . . . . . : 10.20.30.40

Wireless LAN adapter Loopback:

   IPv4 Address. . . . . . . . . . . : 127.0.0.1
EOF
BAT
    close $ipconfig_fh;
    chmod 0755, $ipconfig or die $!;
    no warnings 'redefine';
    local $ENV{PATH} = $fake_bin . ':' . $ENV{PATH};
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ip = sub { return (); };
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ifconfig = sub { return (); };
    is_deeply(
        [ $app->_ip_interface_pairs ],
        [ { iface => 'Ethernet', ip => '10.20.30.40' } ],
        '_ip_interface_pairs falls back to ipconfig parsing when Windows ipconfig output is available',
    );
}

{
    no warnings 'redefine';
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ip = sub { return (); };
    local *Developer::Dashboard::Web::App::_ip_pairs_from_ifconfig = sub { return (); };
    ok( !defined $app->_machine_ip, '_machine_ip returns undef when neither ip nor ifconfig yields a usable address' );
}

my ( $ajax_missing_code, $ajax_missing_type, $ajax_missing_body ) = @{ $app->handle( path => '/ajax', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $ajax_missing_code, 400, 'legacy ajax route rejects requests without token or saved file parameters' );
like( $ajax_missing_type, qr/text\/plain/, 'legacy ajax missing-parameter route returns plain text' );
like( $ajax_missing_body, qr/missing token/, 'legacy ajax missing-parameter route explains the missing token' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    no warnings 'redefine';
    local *Developer::Dashboard::Web::App::decode_payload = sub { die "forced decode failure\n" };
    my ( $ajax_bad_token_code, $ajax_bad_token_type, $ajax_bad_token_body ) = @{ $app->handle( path => '/ajax', query => 'token=known-good-token&type=json', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_bad_token_code, 400, 'legacy ajax route rejects decode failures cleanly' );
    like( $ajax_bad_token_type, qr/text\/plain/, 'legacy ajax decode-failure route returns plain text' );
    like( $ajax_bad_token_body, qr/forced decode failure/, 'legacy ajax decode-failure route returns the decode error text' );
}

{
    my ( $ajax_bad_file_code, $ajax_bad_file_type, $ajax_bad_file_body ) = @{ $app->handle( path => '/ajax', query => 'file=..%2Fbad&type=json', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_bad_file_code, 400, 'legacy ajax route rejects invalid saved bookmark ajax file names cleanly' );
    like( $ajax_bad_file_type, qr/text\/plain/, 'legacy ajax invalid saved-file route returns plain text' );
    like( $ajax_bad_file_body, qr/invalid parent traversal/, 'legacy ajax invalid saved-file route returns the validation error text' );
}

{
    my $streaming_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-stream
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'stream.txt', code => q{
  print "first\n";
  print "second\n";
};
PAGE
    $store->save_page($streaming_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-stream', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_stream_code, $ajax_stream_type, $ajax_stream_body ) = @{ $app->handle( path => '/ajax/stream.txt', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    is( $ajax_stream_code, 200, 'legacy ajax saved-file route responds successfully for streaming output' );
    like( $ajax_stream_type, qr/text\/plain/, 'legacy ajax saved-file route keeps the requested content type for streaming output' );
    is( drain_stream_body($ajax_stream_body), "first\nsecond\n", 'legacy ajax saved-file route streams raw printed output without page buffering' );
}

{
    my $process_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-process
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'process-endpoint.json', code => q{
print "perl-start\n";
warn "perl-warn\n";
system 'sh', '-c', 'printf "child-out\n"; printf "child-err\n" >&2';
die "perl-die\n";
};
PAGE
    $store->save_page($process_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-process', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_process_code, $ajax_process_type, $ajax_process_body ) = @{ $app->handle( path => '/ajax/process-endpoint.json', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_process_output = drain_stream_body($ajax_process_body);
    is( $ajax_process_code, 200, 'legacy ajax saved-file process route responds successfully for mixed stdout and stderr output' );
    like( $ajax_process_type, qr/text\/plain/, 'legacy ajax saved-file process route keeps the requested content type' );
    like( $ajax_process_output, qr/perl-start/, 'legacy ajax saved-file process route streams direct perl stdout' );
    like( $ajax_process_output, qr/perl-warn/, 'legacy ajax saved-file process route streams perl stderr warnings' );
    like( $ajax_process_output, qr/child-out/, 'legacy ajax saved-file process route streams child process stdout' );
    like( $ajax_process_output, qr/child-err/, 'legacy ajax saved-file process route streams child process stderr' );
    like( $ajax_process_output, qr/perl-die/, 'legacy ajax saved-file process route streams uncaught perl die output' );
}

{
    my $singleton_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-singleton
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', singleton => 'FOOBAR', file => 'singleton-endpoint.txt', code => q{
print "$0\n";
};
PAGE
    $store->save_page($singleton_page);
    my ( undef, undef, $singleton_page_body ) = @{ $app->handle( path => '/app/ajax-singleton', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    like( $singleton_page_body, qr{/ajax/singleton-endpoint\.txt\?type=text&singleton=FOOBAR}, 'saved bookmark Ajax page emits the singleton query parameter in the generated ajax url' );
    like( $singleton_page_body, qr/dashboard_ajax_singleton_cleanup\('FOOBAR'\)/, 'saved bookmark Ajax page registers browser lifecycle cleanup for singleton-managed workers' );
    my ( $ajax_singleton_code, undef, $ajax_singleton_body ) = @{ $app->handle( path => '/ajax/singleton-endpoint.txt', query => 'type=text&singleton=FOOBAR', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_singleton_output = drain_stream_body($ajax_singleton_body);
    is( $ajax_singleton_code, 200, 'legacy ajax saved-file route responds successfully for singleton-managed requests' );
    like( $ajax_singleton_output, qr/^dashboard ajax: FOOBAR$/m, 'legacy ajax saved-file route renames singleton-managed Perl workers before streaming output' );
}

{
    my @patterns;
    {
        no warnings 'redefine';
        local *Developer::Dashboard::RuntimeManager::_pkill_perl = sub {
            my ( $self, $pattern ) = @_;
            push @patterns, $pattern;
            return 1;
        };
        my ( $stop_code, undef, $stop_body ) = @{ $app->handle( path => '/ajax/singleton/stop', query => 'singleton=BROWSER-STOP', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
        is( $stop_code, 204, 'singleton stop route returns no content after lifecycle cleanup' );
        is( $stop_body, '', 'singleton stop route keeps the response body empty' );
        my ( $direct_stop_code, undef, $direct_stop_body ) = @{ $app->ajax_singleton_stop_response( query => 'singleton=BROWSER-STOP' ) };
        is( $direct_stop_code, 204, 'direct singleton stop response returns no content after lifecycle cleanup' );
        is( $direct_stop_body, '', 'direct singleton stop response keeps the response body empty' );
    }
    is_deeply(
        \@patterns,
        [ '^dashboard ajax: BROWSER-STOP$', '^dashboard ajax: BROWSER-STOP$' ],
        'singleton stop route and direct response target the matching saved ajax worker process title',
    );
}

{
    my $shebang_page = Developer::Dashboard::PageDocument->from_instruction(<<'PAGE');
BOOKMARK: ajax-shebang
:--------------------------------------------------------------------------------:
HTML: <script>var configs = {};</script>
:--------------------------------------------------------------------------------:
CODE1: Ajax jvar => 'configs.demo.endpoint', type => 'text', file => 'script-runner', code => qq{#!/bin/sh\nprintf 'shell-out\\n'\nprintf 'shell-err\\n' >&2\n};
PAGE
    $store->save_page($shebang_page);
    my ( undef, undef, undef ) = @{ $app->handle( path => '/app/ajax-shebang', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my ( $ajax_shebang_code, undef, $ajax_shebang_body ) = @{ $app->handle( path => '/ajax/script-runner', query => 'type=text', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
    my $ajax_shebang_output = drain_stream_body($ajax_shebang_body);
    is( $ajax_shebang_code, 200, 'legacy ajax saved-file route executes shebang scripts directly' );
    like( $ajax_shebang_output, qr/shell-out/, 'legacy ajax saved-file route streams direct executable stdout' );
    like( $ajax_shebang_output, qr/shell-err/, 'legacy ajax saved-file route streams direct executable stderr' );
}

{
    my $runtime_api_file = File::Spec->catfile( $paths->config_root, 'api.json' );
    open my $runtime_api, '>:raw', $runtime_api_file
      or die "Unable to write runtime api.json: $!";
    print {$runtime_api} qq|{"runtime-client":{"secret":"@{[ sha256_hex('stream-secret') ]}","ajax":["/ajax/stream.txt"]}}|;
    close $runtime_api or die "Unable to close runtime api.json: $!";

    my $skill_api_dir = File::Spec->catdir( $dancer_skill_install->{path}, 'config' );
    make_path($skill_api_dir);
    open my $skill_api, '>:raw', File::Spec->catfile( $skill_api_dir, 'api.json' )
      or die "Unable to write skill api.json: $!";
    print {$skill_api} qq|{"skill-client":{"secret":"@{[ sha256_hex('skill-secret') ]}","ajax":["/ajax/dancer-route-skill/bar"]}}|;
    close $skill_api or die "Unable to close skill api.json: $!";

    my $api_host = 'dashboard-helper.example:7890';
    my ( $registered_no_api_code, $registered_no_api_type, $registered_no_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => { host => $api_host },
    ) };
    is( $registered_no_api_code, 403, 'registered remote ajax route rejects requests that omit API credentials' );
    like( $registered_no_api_type, qr/application\/json/, 'registered remote ajax route returns JSON when API credentials are missing' );
    is_deeply( json_decode( decode_body_text($registered_no_api_body) ), { status => 'forbidden' }, 'registered remote ajax route returns the explicit forbidden JSON payload when API credentials are missing' );

    my ( $registered_bad_api_code, undef, $registered_bad_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => {
            host            => $api_host,
            'x-dd-api-key'    => 'runtime-client',
            'x-dd-api-secret' => 'wrong-secret',
        },
    ) };
    is( $registered_bad_api_code, 403, 'registered remote ajax route rejects invalid API secrets' );
    is_deeply( json_decode( decode_body_text($registered_bad_api_body) ), { status => 'forbidden' }, 'registered remote ajax route keeps the forbidden JSON payload for invalid API secrets' );

    my ( $unregistered_remote_code, undef, $unregistered_remote_body ) = @{ $app->handle(
        path        => '/ajax/process-endpoint.json',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => { host => $api_host },
    ) };
    is( $unregistered_remote_code, 401, 'unregistered remote ajax routes still follow the existing helper-auth flow' );
    is( $unregistered_remote_body, '', 'unregistered remote ajax routes stay silent before helper users exist' );

    my ( $runtime_api_code, $runtime_api_type, $runtime_api_body ) = @{ $app->handle(
        path        => '/ajax/stream.txt',
        query       => 'type=text',
        remote_addr => '127.0.0.1',
        headers     => {
            host              => $api_host,
            'x-dd-api-key'    => 'runtime-client',
            'x-dd-api-secret' => 'stream-secret',
        },
    ) };
    is( $runtime_api_code, 200, 'registered runtime ajax route accepts a matching API key and secret' );
    like( $runtime_api_type, qr/text\/plain/, 'registered runtime ajax route keeps the saved ajax content type under API auth' );
    is( drain_stream_body($runtime_api_body), "first\nsecond\n", 'registered runtime ajax route executes through API auth without requiring a helper session' );

    my ( $skill_api_code, $skill_api_type, $skill_api_body ) = @{ $app->handle(
        path        => '/ajax/dancer-route-skill/bar',
        query       => '',
        remote_addr => '127.0.0.1',
        headers     => {
            host              => $api_host,
            'x-dd-api-key'    => 'skill-client',
            'x-dd-api-secret' => 'skill-secret',
        },
    ) };
    is( $skill_api_code, 200, 'installed skill config/api.json can authorize its ajax routes through API credentials' );
    like( $skill_api_type, qr/application\/json/, 'skill API auth keeps the skill ajax response content type' );
    is( decode_body_text( drain_stream_body($skill_api_body) ), qq|{"route":"dancer-top"}\n|, 'installed skill API auth streams the skill ajax response body' );

    my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );
    Local::PSGITest::test_psgi $psgi_app, sub {
        my ($cb) = @_;
        my $res = $cb->(
            GET(
                'http://127.0.0.1/ajax/stream.txt?type=text',
                Host              => $api_host,
                'X-DD-API-Key'    => 'runtime-client',
                'X-DD-API-Secret' => 'stream-secret',
            )
        );
        is( $res->code, 200, 'PSGI adapter forwards API auth headers to the backend ajax gate' );
        is( decode_body_text( $res->content ), "first\nsecond\n", 'PSGI adapter keeps API-auth ajax output intact' );
    };
}

is( $auth->trust_tier( remote_addr => '127.0.0.1', host => '127.0.0.1:7890' ), 'admin', 'exact loopback with numeric host is admin' );
is( $auth->trust_tier( remote_addr => '127.0.0.1', host => 'localhost:7890' ), 'admin', 'localhost is trusted as admin when it resolves only to loopback' );
is( $auth->trust_tier( remote_addr => '10.0.0.8', host => '127.0.0.1:7890' ), 'helper', 'non-loopback client is helper' );
my @initial_users = $auth->list_users;
is( scalar @initial_users, 0, 'auth store starts empty' );
ok( !$auth->verify_user( username => 'helper', password => 'nope' ), 'missing user does not verify' );
like( $auth->login_page( message => '<unsafe>' ), qr/&lt;unsafe&gt;/, 'login page escapes message content' );
my $helper_host = 'dashboard-helper.example:7890';



( run in 0.500 second using v1.01-cache-2.11-cpan-524268b4103 )