<?php

use Symfony\Component\Process\Process;

/**
 * The root-owned half of an update.
 *
 * An update runs as the service account, so everything on the host that belongs
 * to root — installing a package, writing a unit — is out of its reach, and each
 * such thing turns into "log into every server once and paste this".
 *
 * The tempting fix is a sudo grant on deploy/install-agent.sh. That is root, not
 * a grant: the service account owns the checkout and can rewrite the script it
 * would be allowed to run privileged. So the privileged part is written OUT of
 * the checkout to a root-owned path, and the grant names one exact command line.
 *
 * These tests run the helper as bash actually runs it — the whole point is what
 * it REFUSES, and a refusal is an exit code.
 */
function hostStepScript(): string
{
    $installer = file_get_contents(base_path('deploy/install-agent.sh'));

    // The quoted delimiter matters and is asserted separately below: an
    // unquoted heredoc would expand the installer's own variables into a file
    // that is supposed to be fixed at write time.
    expect($installer)->toContain("cat > \"\$HOST_STEP\" <<'EOF'");

    // betweenFirst, NOT between: `between` cuts at the LAST delimiter, and this
    // installer has several here-documents after this one — so it handed back
    // the helper plus everything down to the final EOF, and the extra `EOF`
    // line then ran as a command. The helper was fine; the measurement was not.
    $body = Str::betweenFirst($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n", "\nEOF\n");

    // The extraction is part of what these tests trust, so it is checked at
    // both ends rather than only at the start.
    expect($body)->toContain('CONTRACT=')
        ->and(rtrim($body))->toEndWith('esac')
        ->and($body)->not->toContain("\nEOF");

    return $body;
}

function runHostStep(string $args, array $onPath = []): Process
{
    $dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6));
    mkdir($dir.'/bin', 0755, true);
    file_put_contents($dir.'/clupilot-host-step', hostStepScript());
    chmod($dir.'/clupilot-host-step', 0755);

    // A stub apt-get that fails loudly. Any test that does not expect an
    // install will see it in the output rather than silently tolerating one.
    file_put_contents($dir.'/bin/apt-get', "#!/bin/sh\necho 'APT-GET WAS CALLED' >&2\nexit 1\n");
    chmod($dir.'/bin/apt-get', 0755);

    foreach ($onPath as $name => $exit) {
        file_put_contents($dir."/bin/{$name}", "#!/bin/sh\nexit {$exit}\n");
        chmod($dir."/bin/{$name}", 0755);
    }

    $process = Process::fromShellCommandline(
        'PATH='.escapeshellarg($dir.'/bin').':/usr/bin:/bin '
        .escapeshellarg($dir.'/clupilot-host-step').' '.$args
    );
    $process->run();

    exec('rm -rf '.escapeshellarg($dir));

    return $process;
}

it('refuses a step it does not know by name', function () {
    // The whole safety argument rests on this. sudoers permits one exact
    // command line, and this is the second of the two locks: even reached some
    // other way, the helper does nothing it was not built to do.
    $process = runHostStep('install-whatever-i-like');

    expect($process->getExitCode())->toBe(64)
        ->and($process->getErrorOutput())->toContain('unknown step')
        ->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED');
});

it('refuses being run with no step at all', function () {
    // `set -u` would otherwise end this as an unbound-variable error, which is
    // the right outcome by accident and reads as a bug in the helper.
    $process = runHostStep('');

    expect($process->getExitCode())->toBe(64)
        ->and($process->getErrorOutput())->toContain('unknown step');
});

it('does not run a package manager when rsync is already there', function () {
    $process = runHostStep('ensure-rsync', ['rsync' => 0]);

    expect($process->getExitCode())->toBe(0)
        ->and($process->getErrorOutput())->not->toContain('APT-GET WAS CALLED');
});

it('says so rather than pretending, on a host with no apt-get', function () {
    // Nothing here is Debian-only by design, but the install step is. Silence
    // would leave an operator believing rsync is on a machine where it is not,
    // and they would find out from a backup that collected nothing.
    $dir = sys_get_temp_dir().'/clupilot-host-step-'.bin2hex(random_bytes(6));
    mkdir($dir.'/bin', 0755, true);
    file_put_contents($dir.'/clupilot-host-step', hostStepScript());
    chmod($dir.'/clupilot-host-step', 0755);

    // An empty PATH, and bash named outright — the helper's own `#!/usr/bin/env
    // bash` would not find an interpreter otherwise, and that 127 looks exactly
    // like the refusal being tested here.
    $process = new Process(
        [trim((string) shell_exec('command -v bash')), $dir.'/clupilot-host-step', 'ensure-rsync'],
        null,
        ['PATH' => $dir.'/bin'],
    );
    $process->run();
    exec('rm -rf '.escapeshellarg($dir));

    expect($process->getExitCode())->toBe(3)
        ->and($process->getErrorOutput())->toContain('no apt-get');
});

it('reports the contract version the updater compares against', function () {
    $process = runHostStep('contract');

    expect($process->getExitCode())->toBe(0)
        ->and(trim($process->getOutput()))->toMatch('/^\d+$/');

    // And the two halves agree. A helper written by an older installer is the
    // failure mode this number exists for; if update.sh asked for a version the
    // current installer never writes, every up-to-date server would be told to
    // run the installer again forever.
    $needs = Str::between(file_get_contents(base_path('deploy/update.sh')), 'HOST_STEP_NEEDS=', "\n");

    expect((int) trim($process->getOutput()))->toBe((int) trim($needs));
});

it('grants one command line, not a script the service account can rewrite', function () {
    $installer = file_get_contents(base_path('deploy/install-agent.sh'));

    expect($installer)
        // Root's, at a path under root's — the service account cannot edit what
        // it is allowed to run. This is the entire safety argument.
        ->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step')
        ->toContain('chown root:root "$HOST_STEP"')
        ->toContain('chmod 0755 "$HOST_STEP"')
        // Validated before it counts. A malformed fragment in /etc/sudoers.d
        // breaks sudo for everyone on the machine, not only for this grant.
        ->toContain('visudo -cf /etc/sudoers.d/clupilot-host-step')
        // Both halves must mean the same path.
        ->and(file_get_contents(base_path('deploy/update.sh')))
        ->toContain('HOST_STEP=/usr/local/sbin/clupilot-host-step');

    // Every sudo grant this installer writes, read as sudoers reads it.
    preg_match_all('/^\S+\s+ALL=\(root\)\s+NOPASSWD:\s*(.+)$/m', $installer, $m);

    expect($m[1])->not->toBeEmpty();

    foreach ($m[1] as $granted) {
        // An argument, always. A grant on a bare path permits that path with
        // ANY arguments — so every step ever added to the helper would be
        // covered by a decision taken before it existed.
        expect(trim($granted))->toContain(' ')
            // And never anything inside the checkout, which the service account
            // owns: it could rewrite the file and the grant would follow.
            ->not->toContain('$ROOT')
            ->not->toContain('deploy/');
    }

    expect($m[1])->toContain('$HOST_STEP ensure-rsync');
});

it('never lets a host package stop a deployment', function () {
    // rsync is wanted by whoever collects the invoice archive, minutes or hours
    // later. An update that died because a package manager was busy would be a
    // far bigger problem than a backup waiting for the next run.
    $update = file_get_contents(base_path('deploy/update.sh'));

    $call = Str::between($update, 'if ! command -v rsync', 'phase maintenance_off');

    expect($call)->toContain('sudo -n "$HOST_STEP" ensure-rsync >/dev/null 2>&1')
        ->and($call)->toContain('warn ');
});
