Developer-Dashboard

 view release on metacpan or  search on metacpan

t/101-web-app-coverage.t  view on Meta::CPAN

# =====================================================================
is( $app->handle( path => '/app/route-skill', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill index' );
is( $app->handle( path => '/app/route-skill/edit', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill index edit' );
is( $app->handle( path => '/app/route-skill/source', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill index source' );
is( $app->handle( path => '/app/route-skill/foo', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill page' );
is( $app->handle( path => '/app/route-skill/ajax-demo', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill ajax-demo page' );
is( $app->handle( path => '/app/route-skill/missing', remote_addr => '127.0.0.1', headers => H() )->[0], 404, 'skill missing bookmark 404' );
is( $app->handle( path => '/app/route-skill/def', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'nested skill index' );
is( $app->handle( path => '/app/route-skill/def/edit', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'nested skill edit' );
is( $app->handle( path => '/app/route-skill/def/foo', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'nested skill page' );
is( $app->handle( path => '/app/missing-skill/foo', remote_addr => '127.0.0.1', headers => H() )->[0], 404, 'missing nested skill 404' );

# skill edit POST render (source_kind skill render path)
{
    my $instr = "TITLE: Skill Route Index\n:--------------------------------------------------------------------------------:\nBOOKMARK: index\n:--------------------------------------------------------------------------------:\nHTML:\nEdited Skill In...
    is( $app->handle( path => '/app/route-skill/edit', method => 'POST', body => 'mode=render&instruction=' . uri_escape($instr), remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill edit POST render' );
}

# custom skill routes
is( $app->handle( path => '/apps/route-skill/home', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill app route' );
is( $app->handle( path => '/apps/route-skill/child/foo', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'nested custom skill app route' );
is( $app->handle( path => '/v1/route-skill/bar', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill ajax route' );
is( $app->handle( path => '/v1/route-skill/raw', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill ajax raw mime' );
is( $app->handle( path => '/assets/route-skill.js', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill js route' );
is( $app->handle( path => '/assets/route-skill.css', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill css route' );
is( $app->handle( path => '/downloads/route-skill.txt', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'custom skill others route' );

# skill-local static + ajax routes
is( $app->handle( path => '/js/route-skill/skill.js', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill-local js' );
is( $app->handle( path => '/css/route-skill/skill.css', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill-local css' );
is( $app->handle( path => '/others/route-skill/info.txt', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill-local others' );
is( $app->handle( path => '/ajax/route-skill/bar', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'skill-local ajax' );

# legacy /skill/<repo>/<route>
is( $app->handle( path => '/skill/route-skill/bookmarks/foo', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'legacy /skill route' );

# global fallback when first segment matches skill name but asset is global
wfile( File::Spec->catfile( $paths->dashboards_root, 'public', 'js', 'route-skill', 'fallback.js' ), qq|console.log("global");\n|, 0644 );
is( $app->handle( path => '/js/route-skill/fallback.js', remote_addr => '127.0.0.1', headers => H() )->[0], 200, 'global js fallback under skill name' );

# =====================================================================
# 5. API machine-auth + helper login/logout (non-loopback host -> non-admin).
# =====================================================================
{
    my $api_json = sprintf
        '{"good":{"secret":"%s","ajax":["/ajax/demo.json"]},"nosec":{"ajax":["/ajax/nosec"]},"bad1":"string","bad2":{"ajax":"notarray","secret":"z"}}',
        sha256_hex('sekret');
    wfile( File::Spec->catfile( $paths->config_root, 'api.json' ), $api_json, 0644 );
    my $api_config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
    my $app_api    = Developer::Dashboard::Web::App->new(
        actions  => $actions,
        auth     => $auth,
        config   => $api_config,
        pages    => $store,
        prompt   => $prompt,
        runtime  => $runtime,
        sessions => $sessions,
    );
    my $ah = 'helper.example:7890';

    # registered ajax route, no credentials -> forbidden (api path, no session)
    is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 403, 'registered api route without creds -> 403' );
    # registered ajax route with valid credentials -> 200
    is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah, 'x-dd-api-key' => 'good', 'x-dd-api-secret' => 'sekret' } )->[0], 200, 'registered api route with creds -> 200' );
    # unregistered route, no helper users yet -> helper access disabled (401 empty)
    is( $app_api->handle( path => '/app/welcome', query => '', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider before helper users -> 401' );

    # create a helper user; now outsiders get a login page
    $auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
    is( $app_api->handle( path => '/app/welcome', query => 'x=1', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider after helper users -> login 401' );

    # bad + good login
    is( $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=wrong', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'bad login -> 401' );
    my $login = $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=helper-pass-123&redirect_to=/app/welcome', remote_addr => '203.0.113.7', headers => { host => $ah } );
    is( $login->[0], 302, 'good login -> 302' );
    my $cookie = $login->[3]{'Set-Cookie'};

    # DD-415: a malicious backslash-authority redirect_to (%2F%5C -> "/\") must
    # never reach the 302 Location header; browsers treat "/\evil.com" as an
    # off-site authority, so the sanitizer has to collapse it to "/".
    my $evil_login = $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=helper-pass-123&redirect_to=%2F%5Cevil.com', remote_addr => '203.0.113.7', headers => { host => $ah } );
    is( $evil_login->[0], 302, 'malicious backslash redirect still authenticates' );
    is( $evil_login->[3]{Location}, '/', 'DD-415: backslash-authority redirect_to collapses to / in Location header' );

    # DD-415: same bypass class through a raw tab (%2F%09%2F -> "/\t/"). A URL
    # parser strips the tab before parsing, so an unsanitized target reaches the
    # browser as "//evil.com" and becomes an off-site protocol-relative redirect.
    my $tab_login = $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=helper-pass-123&redirect_to=%2F%09%2Fevil.com', remote_addr => '203.0.113.7', headers => { host => $ah } );
    is( $tab_login->[0], 302, 'malicious tab redirect still authenticates' );
    is( $tab_login->[3]{Location}, '/', 'DD-415: tab-hidden authority redirect_to collapses to / in Location header' );

    # registered ajax route WITH session cookie -> session path (skip api)
    is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah, cookie => $cookie } )->[0], 200, 'session cookie keeps ajax access' );
    # unregistered route WITH session cookie -> authorized
    ok( $app_api->handle( path => '/app/welcome', query => '', remote_addr => '203.0.113.7', headers => { host => $ah, cookie => $cookie } )->[0], 'session cookie authorizes app route' );
    # DD-605: Web::App::handle() applies the CSRF fetch-metadata check before
    # dispatching to /logout, but Web::DancerApp's actual route for GET
    # /logout calls logout_response() DIRECTLY via _run_backend, bypassing
    # handle() (and its CSRF check) entirely - the same architectural shape
    # POST /login has, which login_response() correctly compensates for by
    # calling _csrf_rejection_response itself. This test calls
    # logout_response() directly, matching exactly what the real Dancer2
    # route does, so it actually exercises the vulnerable path rather than
    # handle()'s already-protected one. For a helper session this isn't just
    # an unwanted logout, it permanently deletes the account (the "throwaway
    # helper account" design, t/125).
    my ($cookie_session_id) = $cookie =~ /dashboard_session=([^;]+)/;
    ok( $sessions->get($cookie_session_id), 'helper session exists before the cross-site logout attempt' );
    ok( $auth->get_user('helper'), 'helper account exists before the cross-site logout attempt' );
    my $foreign_logout = $app_api->logout_response(
        remote_addr => '203.0.113.7',
        headers     => { host => $ah, cookie => $cookie, 'sec-fetch-site' => 'cross-site' },
    );
    is( $foreign_logout->[0], 403, 'cross-site GET /logout (direct logout_response call, matching the real Dancer2 route) is rejected, not silently actioned' );
    ok( $sessions->get($cookie_session_id), 'helper session still exists after the rejected cross-site logout' );
    ok( $auth->get_user('helper'), 'helper account still exists after the rejected cross-site logout' );

    # logout removes the helper user (role helper + username set)
    is( $app_api->handle( path => '/logout', remote_addr => '203.0.113.7', headers => { host => $ah, cookie => $cookie } )->[0], 302, 'logout with helper session' );
    # logout with no session
    is( $app_api->handle( path => '/logout', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 302, 'logout with no session' );

    # logout with a non-helper session (role admin, username empty -> 376 false side)



( run in 1.533 second using v1.01-cache-2.11-cpan-007c89162af )