Developer-Dashboard

 view release on metacpan or  search on metacpan

t/10-extension-action-docker.t  view on Meta::CPAN

    page   => $saved_page,
    source => 'saved',
);
like( $paths_result->{body}, qr/"runtime"/, 'builtin paths.list action returns runtime data' );

my ($command_action) = grep { $_->{id} eq 'run' } @{ $saved_page->as_hash->{actions} };
my $command_result = $actions->run_page_action(
    action => $command_action,
    page   => $saved_page,
    source => 'saved',
);
is( $command_result->{exit_code}, 0, 'trusted saved page command action executes' );
like( $command_result->{stdout}, qr/shell-output/, 'trusted command action captures stdout' );

my $env_result = $actions->run_command_action(
    command    => 'printf "$ACTION_ENV"',
    cwd        => $repo,
    env        => { ACTION_ENV => 'env-ok' },
    timeout_ms => 1000,
);
like( $env_result->{stdout}, qr/env-ok/, 'command actions inject explicit env values' );

my $timeout_result = $actions->run_command_action(
    command    => "$^X -e 'sleep 2'",
    cwd        => $repo,
    timeout_ms => 200,
);
is( $timeout_result->{exit_code}, 124, 'command action timeout returns timeout exit code' );
ok( $timeout_result->{timed_out}, 'command action timeout is marked' );

my $background_result = $actions->run_command_action(
    command    => "$^X -e 'sleep 1'",
    cwd        => $repo,
    background => 1,
);
ok( $background_result->{pid} > 0, 'background command action returns a child pid' );
ok( $actions->_pid_is_running( $background_result->{pid} ), 'background action child is running initially' );
kill 'TERM', $background_result->{pid};
my $background_wait_loops = $INC{'Devel/Cover.pm'} ? 100 : 20;
for ( 1 .. $background_wait_loops ) {
    last if !$actions->_pid_is_running( $background_result->{pid} );
    sleep 0.1;
}
ok( !$actions->_pid_is_running( $background_result->{pid} ), 'background action child stops cleanly without leaving a direct child for the caller to reap' );

my $transient = Developer::Dashboard::PageDocument->new(
    title       => 'Transient',
    actions     => [
        { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
    ],
    permissions => {},
);
my $transient_page = $transient;
eval {
    $actions->run_page_action(
        action => { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
        page   => $transient_page,
        source => 'transient',
    );
};
like( $@, qr/not trusted/, 'transient encoded page command action is blocked by default' );

eval {
    $actions->run_page_action(
        action => { id => 'bad', kind => 'weird' },
        page   => $saved_page,
        source => 'saved',
    );
};
like( $@, qr/Unsupported action kind/, 'unsupported action kinds are rejected' );

eval {
    $actions->run_page_action(
        action => { id => 'bad', kind => 'builtin', builtin => 'unknown' },
        page   => $saved_page,
        source => 'saved',
    );
};
like( $@, qr/Unsupported builtin action/, 'unsupported builtin actions are rejected' );

my $transient_allowed = Developer::Dashboard::PageDocument->new(
    title       => 'Transient Allowed',
    actions     => [
        { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    ],
    permissions => {
        allow_untrusted_actions => 1,
        trusted_actions         => ['run'],
    },
);
my $allowed_page = $transient_allowed;
my $allowed_result = $actions->run_page_action(
    action => { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    page   => $allowed_page,
    source => 'transient',
);
like( $allowed_result->{stdout}, qr/allowed/, 'transient encoded page can opt in specific trusted actions' );

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    local $ENV{DOCKER_SKILL_BASE} = 'skill-base';
    local $ENV{DD_TEST_DOCKER_ROOT} = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker' );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_ROOT}/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_MISSING}green/compose.yml'),
        'green/compose.yml',
        'docker compose resolver collapses undefined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('$DD_TEST_DOCKER_ROOT/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined bare environment variables in configured compose paths',

t/10-extension-action-docker.t  view on Meta::CPAN

my $auth = Developer::Dashboard::Auth->new( files => $files, paths => $paths );
my $sessions = Developer::Dashboard::SessionStore->new( paths => $paths );
my $app = Developer::Dashboard::Web::App->new(
    actions  => $actions,
    auth     => $auth,
    pages    => $pages,
    resolver => $resolver,
    sessions => $sessions,
);

my ( $provider_code, undef, $provider_body ) = @{ $app->handle( path => '/app/shared-provider', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $provider_code, 200, 'provider page renders through web app' );
like( $provider_body, qr/Shared Provider/, 'provider page content is rendered' );

my ( $state_render_code, undef, $state_render_body ) = @{ $app->handle( path => '/app/action-page', query => 'filter=active', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $state_render_code, 200, 'saved page render with query state succeeds' );
like( $state_render_body, qr/active/, 'query parameters are reflected into page state rendering' );

my $atoken = $actions->encode_action_payload(
    action => $saved_page->as_hash->{actions}[0],
    page   => $saved_page,
    source => 'saved',
);
my ( $encoded_action_code, $encoded_action_type, $encoded_action_body ) = @{ $app->handle(
    path        => '/action',
    method      => 'POST',
    query       => 'atoken=' . uri_escape($atoken),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $encoded_action_code, 403, 'encoded action route is denied by default' );
like( $encoded_action_type, qr/text\/plain/, 'encoded action route denial returns plain text' );
like( $encoded_action_body, qr/Transient token URLs are disabled/, 'encoded action route denial explains the policy' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $allowed_encoded_action_code, $allowed_encoded_action_type, $allowed_encoded_action_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => 'atoken=' . uri_escape($atoken),
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };
    is( $allowed_encoded_action_code, 200, 'encoded action route executes when transient token URLs are enabled' );
    like( $allowed_encoded_action_type, qr/application\/json/, 'encoded action route returns json when transient token URLs are enabled' );
    like( $allowed_encoded_action_body, qr/"alpha"\s*:\s*"one"/, 'encoded action route returns page state when transient token URLs are enabled' );
}

my $transient_safe = Developer::Dashboard::PageDocument->new(
    title   => 'Transient Safe',
    actions => [
        { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    ],
    state => { beta => 'two' },
);
my $token = $actions->encode_action_payload(
    action => { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    page   => $transient_safe,
    source => 'transient',
);
my ( $transient_blocked_code, undef, $transient_blocked_body ) = @{ $app->handle(
    path        => '/action',
    method      => 'POST',
    query       => 'atoken=' . uri_escape($token),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $transient_blocked_code, 403, 'transient encoded builtin action route is denied by default' );
like( $transient_blocked_body, qr/Transient token URLs are disabled/, 'transient encoded builtin action denial explains the policy' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $transient_code, $transient_type, $transient_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => 'atoken=' . uri_escape($token),
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };
    is( $transient_code, 200, 'transient encoded builtin action route executes for safe actions when transient token URLs are enabled' );
    like( $transient_type, qr/application\/json/, 'transient encoded builtin action route returns json when transient token URLs are enabled' );
    like( $transient_body, qr/"beta"\s*:\s*"two"/, 'transient encoded builtin action route returns action output when transient token URLs are enabled' );
}

my ( $missing_action_code ) = @{ $app->handle(
    path        => '/app/action-page/action/missing',
    method      => 'POST',
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $missing_action_code, 404, 'missing page actions return not found' );

my $app_without_actions = Developer::Dashboard::Web::App->new(
    auth     => $auth,
    pages    => $pages,
    resolver => $resolver,
    sessions => $sessions,
);
my ( $no_runner_code ) = @{ $app_without_actions->handle(
    path        => '/app/action-page/action/state',
    method      => 'POST',
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $no_runner_code, 501, 'web action routes return not implemented when no action runner is configured' );

$auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
my @users_before_remove = $auth->list_users;
ok( scalar(@users_before_remove), 'helper users can be listed before removal' );
$auth->remove_user('helper');
my @users_after_remove = $auth->list_users;
is( scalar(@users_after_remove), 0, 'remove_user deletes helper records' );

done_testing;

__END__

=head1 NAME

10-extension-action-docker.t - extension, action, and docker resolver tests

=head1 DESCRIPTION

This test verifies config-driven extensions, page actions, encoded action
transport, and docker compose resolution behavior.

=for comment FULL-POD-DOC START

=head1 PURPOSE



( run in 2.401 seconds using v1.01-cache-2.11-cpan-75ffa21a3d4 )