App-Ariza.git | t/ | 21-update-integration.rakutest


use v6.d;
use Test;
use JSON::Fast;

use App::Ariza::Bundle;
use App::Ariza::CI;
use App::Ariza::Config;
use App::Ariza::Launcher;
use App::Ariza::Licensing;
use App::Ariza::Site;
use App::Ariza::Tools;
use App::Ariza::Update;
use App::Ariza::Versions;

plan 6;

sub temp-dir(Str $leaf --> IO::Path) {
    ensure-dir($*TMPDIR.add("ariza-update-integration-{$*PID}-{(^1_000_000).pick}")
        .add($leaf))
}

my $scratch = temp-dir('root');
END { rm-rf($scratch) }

sub config(Str $leaf, Bool :$enabled = False,
           :@platforms = <macos-arm64 windows-x86_64> --> App::Ariza::Config) {
    my $dir = ensure-dir($scratch.add($leaf));
    $dir.add('ariza.toml').spurt(
        "[app]\nname = \"App::ExampleApp\"\nexec = \"exampleapp\"\n"
      ~ "display = \"Example App\"\n[bundle]\nplatforms = ["
      ~ @platforms.map({ "\"$_\"" }).join(', ') ~ "]\n"
      ~ "[installer]\nrepo = \"example-org/App-ExampleApp\"\n"
      ~ ($enabled ?? "[updates]\nenabled = true\n" !! ''));
    App::Ariza::Config.load($dir)
}

my $disabled = config('disabled');
my $enabled  = config('enabled', :enabled);
my $site-target = App::Ariza::Site.site-rel('.') ~ '/bin/exampleapp.raku';

subtest 'bundle assembly stages only opted-in update resources', {
    plan 13;
    my $off = ensure-dir($scratch.add('bundle-off'));
    is-deeply App::Ariza::Bundle.stage-updater(
        :work($off), :config($disabled), :version<1.2.3>,
        :platform<macos-arm64>, :application-target($site-target)), %(),
        'disabled bundles stage nothing';
    nok $off.add('libexec').e, 'and retain the old layout';

    my $on = ensure-dir($scratch.add('bundle-on'));
    my %u = App::Ariza::Bundle.stage-updater(
        :work($on), :config($enabled), :version<1.2.3>,
        :platform<macos-arm64>, :application-target($site-target));
    is %u<coordinator-rel>, 'libexec/ariza/update.raku',
        'the coordinator has the frozen location';
    is %u<installer-rel>, 'libexec/ariza/install.sh',
        'POSIX gets its local installer snapshot';
    ok %u<coordinator>.f && %u<installer>.f, 'both files are staged';
    if $*DISTRO.is-win {
        skip 'POSIX executable bits are not available on Windows', 1;
    }
    else {
        ok %u<installer>.x, 'the local POSIX installer is executable';
    }
    ok %u<coordinator>.slurp.contains("has Str \$.app-version   = '1.2.3'"),
        'the coordinator bakes the exact version';
    ok %u<installer>.slurp.contains('--ariza-update-candidate'),
        'the snapshot carries private exact-candidate mode';

    my %m = App::Ariza::Bundle.updates-component(%u);
    is %m<protocol>, 1, 'manifest protocol is fixed at one';
    ok %m<enabled>, 'manifest records explicit enablement';
    is %m<repository>, 'example-org/App-ExampleApp', 'and release repository';
    is %m<coordinator>, 'libexec/ariza/update.raku', 'with a relative coordinator';
    is %m<application-target>, $site-target,
        'while preserving the real app target for validation';
}

subtest 'update-enabled launchers target and supervise the coordinator', {
    plan 12;
    my %posix = App::Ariza::Launcher.context(
        :config($enabled), :slug<macos-arm64>,
        :target(App::Ariza::Update.coordinator-rel), :app-version<1.2.3>);
    my $sh = App::Ariza::Launcher.render(:template<launcher-posix.sh.j2>, |%posix);
    ok %posix<updates_enabled>, 'enablement reaches the renderer';
    ok $sh.contains('"$BUNDLE_ROOT/libexec/ariza/update.raku" "$@"'),
        'the coordinator receives the original argv';
    ok $sh.contains('ARIZA_STATUS') && $sh.contains('"$ARIZA_STATUS" -eq 75'),
        'the launcher supervises the reserved status';
    ok $sh.contains("printf 'protocol=1\\nnonce=%s\\ncandidate=%s\\n'"),
        'handoff bytes are compared against the private nonce and candidate';
    ok $sh.contains('ARIZA_UPDATE_RELAUNCHED=1'), 'the handoff is one hop';

    my %win = App::Ariza::Launcher.context(
        :config($enabled), :slug<windows-x86_64>,
        :target(App::Ariza::Update.coordinator-rel), :app-version<1.2.3>);
    my $side = App::Ariza::Launcher.render(
        :template<launcher-windows.ariza.j2>, |%win);
    ok $side.contains("target libexec\\ariza\\update.raku"),
        'the native runner targets the coordinator';
    ok $side.contains('set ARIZA_UPDATES_ENABLED=1'),
        'and receives explicit update enablement';
    my $ps = App::Ariza::Launcher.render(:template<launcher-windows.ps1.j2>, |%win);
    my $cmd = App::Ariza::Launcher.render(:template<launcher-windows.cmd.j2>, |%win);
    ok $ps.contains("Join-Path \$PSScriptRoot 'exampleapp.exe'"),
        'PowerShell delegates to the native runner';
    ok $cmd.contains('"%~dp0exampleapp.exe" %*'),
        'as does CMD';
    ok $ps.index('$ArizaRunner') < $ps.index('& $ArizaRaku'),
        'PowerShell delegates before its disabled-mode implementation';
    ok $cmd.index('"%~dp0exampleapp.exe"') < $cmd.index('"%ARIZA_RAKU%"'),
        'CMD delegates before its disabled-mode implementation';
    ok $side.contains("\r\n"), 'Windows update output remains CRLF';
}

subtest 'update-enabled Windows refuses an unauthenticated script-only build', {
    plan 4;
    my $dir = ensure-dir($scratch.add('windows-launchers'));
    throws-like {
        App::Ariza::Launcher.write(
            :bundle-dir($dir), :config($enabled), :slug<windows-x86_64>,
            :target(App::Ariza::Update.coordinator-rel),
            :stage-runner(-> | { %() }))
    }, Exception, message => /'require updater-capable runner-v2+'/,
        'no runner is a hard build failure';

    throws-like {
        App::Ariza::Launcher.write(
            :bundle-dir($dir), :config($enabled), :slug<windows-x86_64>,
            :target(App::Ariza::Update.coordinator-rel),
            :stage-runner(-> :$bundle-dir, :$exec, *% {
                my $path = $bundle-dir.IO.add('bin').add("$exec.exe");
                $path.spurt('MZ');
                %( :$path, tag => 'runner-v1' )
            }));
    }, Exception, message => /'runner-v2+'/,
       'the old pinned runner is rejected even when its file exists';

    my %written = App::Ariza::Launcher.write(
        :bundle-dir($dir), :config($enabled), :slug<windows-x86_64>,
        :target(App::Ariza::Update.coordinator-rel),
        :stage-runner(-> :$bundle-dir, :$exec, *% {
            my $path = $bundle-dir.IO.add('bin').add("$exec.exe");
            $path.spurt('MZ');
            %( :$path, tag => 'runner-v2', artifact => 'runner.exe',
               url => 'https://example.invalid/runner.exe', sha256 => 'a' x 64 )
        }));
    ok %written<runner><path>.f, 'a staged runner satisfies the requirement';
    is %written<written>.tail.basename, 'exampleapp.exe',
        'and is part of the launcher inventory';
}

subtest 'POSIX authenticated handoff preserves argv and false 75 does not relaunch', {
    plan 7;
    if $*DISTRO.is-win || !have-command('sh') {
        skip 'POSIX launcher execution needs sh', 7;
    }
    else {
        my $root = ensure-dir($scratch.add('managed'));
        my $versions = ensure-dir($root.add('versions'));
        my $old = ensure-dir($versions.add('1.2.3'));
        my $new = ensure-dir($versions.add('1.2.4'));
        ensure-dir($old.add('bin'));
        ensure-dir($old.add('rakudo/bin'));
        ensure-dir($old.add('libexec/ariza')).add('update.raku').spurt("# fixture\n");
        ensure-dir($new.add('bin')).add('exampleapp').spurt(q:to/SH/);
#!/bin/sh
printf 'relaunch-guard=%s\n' "${ARIZA_UPDATE_RELAUNCHED:-}"
printf 'arg=<%s>\n' "$@"
SH
        $new.add('bin/exampleapp').chmod(0o755);

        my $raku = $old.add('rakudo/bin/raku');
        $raku.spurt(q:to/SH/);
#!/bin/sh
case ${ARIZA_TEST_MODE:-natural} in
  install)
    install_root=$(dirname "$(dirname "$(dirname "$ARIZA_UPDATE_HANDOFF")")")
    rm -f "$install_root/current"
    ln -s "versions/1.2.4" "$install_root/current"
    printf 'protocol=1\nnonce=%s\ncandidate=1.2.4\n' \
      "$ARIZA_UPDATE_NONCE" >"$ARIZA_UPDATE_HANDOFF"
    ;;
esac
exit 75
SH
        $raku.chmod(0o755);
        my %ctx = App::Ariza::Launcher.context(
            :config($enabled), :slug<macos-arm64>,
            :target(App::Ariza::Update.coordinator-rel), :app-version<1.2.3>);
        my $launcher = $old.add('bin/exampleapp');
        $launcher.spurt(App::Ariza::Launcher.render(
            :template<launcher-posix.sh.j2>, |%ctx));
        $launcher.chmod(0o755);

        sub point-current(Str $version) {
            $root.add('current').unlink if $root.add('current').e || $root.add('current').l;
            run-checked(['ln', '-s', "versions/$version", $root.add('current').absolute],
                        :what<ln>);
        }
        point-current('1.2.3');
        my %env = PATH => '/usr/bin:/bin', HOME => $scratch.absolute,
                  TERM => 'xterm-256color', ARIZA_TEST_MODE => 'natural';
        my ($natural, $, $natural-err) = try-run(
            [$launcher.absolute, 'not-an-update'], :%env);
        is $natural, 75, 'an ordinary status 75 is returned unchanged'
            or diag $natural-err;
        is $root.add('current').resolve.basename, '1.2.3',
            'and cannot switch current without a handoff';

        point-current('1.2.3');
        %env<ARIZA_TEST_MODE> = 'install';
        my ($code, $out, $err) = try-run(
            [$launcher.absolute, 'one two', q{$(literal)}, '"quoted"'], :%env);
        is $code, 0, 'a valid committed handoff relaunches successfully'
            or diag $err;
        is $root.add('current').resolve.basename, '1.2.4',
            'only the validated managed candidate is selected';
        ok $out.contains('relaunch-guard=1'), 'the next launcher is guarded';
        ok $out.contains("arg=<one two>\narg=<\$(literal)>\narg=<\"quoted\">") ,
            'the original argv survives the handoff exactly' or diag $out;
        nok $root.add('.ariza/update-v1').dir.grep(*.basename.starts-with('handoff')),
            'challenge and expected-record files are cleaned up';
    }
}

subtest 'only opted-in CI gets the exact bare tag preflight', {
    plan 4;
    my $pin = ensure-dir($scratch.add('pins')).add('versions.toml');
    $pin.spurt("sqlcipher = \"4.14.0\"\n[rakudo]\nversion = \"2026.07\"\nrevision = \"01\"\n");
    my $versions = App::Ariza::Versions.load($pin);
    my $off = App::Ariza::CI.render(:template<release.yml.j2>,
        |App::Ariza::CI.context(:config(config('ci-off',
                                               :platforms(['macos-arm64']))),
                                :$versions, :ariza-version<test>));
    my $on = App::Ariza::CI.render(:template<release.yml.j2>,
        |App::Ariza::CI.context(:config(config('ci-on', :enabled,
                                               :platforms(['macos-arm64']))),
                                :$versions, :ariza-version<test>));
    nok $off.contains('Require an exact bare update version tag'),
        'disabled workflow remains unchanged';
    ok $on.contains('Require an exact bare update version tag'),
        'opted-in workflow has a named preflight';
    ok $on.contains('^[0-9]+\.[0-9]+\.[0-9]+$'), 'the grammar is exact X.Y.Z';
    ok $on.contains('bare X.Y.Z tag'), 'failure explains the release contract';
}

subtest 'licensing inventory accounts for generated updater files', {
    plan 4;
    my %p =
        'rakudo-version' => '2026.07', 'rakudo-tag' => '2026.07-01',
        'rakudo-url' => 'https://example.invalid/rakudo',
        'sqlcipher-version' => '', 'sqlcipher-source' => '',
        'runner-tag' => '', 'runner-url' => '',
        'app-exec' => 'exampleapp', 'ariza-version' => '0.1.3';
    my @plain = App::Ariza::Licensing.runtime-rows(:placeholders(%p));
    nok @plain.first(*.<id> eq 'ariza-updater'), 'disabled inventory has no row';
    my @with = App::Ariza::Licensing.runtime-rows(
        :placeholders(%p), :conditions(['updater']),
        :files({ 'ariza-updater' => <libexec/ariza/update.raku libexec/ariza/install.sh> }));
    my $row = @with.first(*.<id> eq 'ariza-updater');
    ok $row.defined, 'enabled inventory includes generated update support';
    is $row<spdx>, 'Artistic-2.0', 'under App-Ariza licensing';
    is-deeply $row<files>.List,
        <libexec/ariza/update.raku libexec/ariza/install.sh>,
        'and names both staged resources';
}

done-testing;