Files
coolify/tests/Unit/SshMultiplexingDisableTest.php
Andras Bacsai e2c826a976 fix: run SSH wrapper via sh and scope secret tokens to resource team
SSH: sshd runs the remote command with the user's login shell, which
can be fish or csh/tcsh. Those shells cannot parse POSIX `if ... fi`, so
the bash/sh fallback wrapper now runs inside `sh -c "..."`. Unit tests
run the wrapper under every login shell that is installed and check that
it stays one simple sh command.

Secret managers: the SecretManagerLinks component now lists and resolves
tokens by the resource's team instead of the session team. A team switch
in another tab can no longer link a token from a different team.
SecretManagerLink::fetchSecrets() also throws when the linked token does
not belong to the resource's team, so no request is sent with it.

Also adds a lesson note: remove the SSH mux file before live SSH or
login-shell tests.
2026-09-30 11:05:42 +02:00

142 lines
5.6 KiB
PHP

<?php
namespace Tests\Unit;
use App\Helpers\SshMultiplexingHelper;
use Tests\TestCase;
/**
* Tests for SSH multiplexing disable functionality.
*
* These tests verify the parameter signatures for the disableMultiplexing feature
* which prevents race conditions when multiple scheduled tasks run concurrently.
*
* @see https://github.com/coollabsio/coolify/issues/6736
*/
class SshMultiplexingDisableTest extends TestCase
{
/**
* sshd runs the remote command with the SSH user's login shell (`$SHELL -c '<command>'`),
* which can be fish or csh/tcsh. The wrapper must work in each of them.
*/
public function test_remote_shell_runs_the_script_from_stdin_in_every_login_shell()
{
$wrapper = (new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'))->invoke(null);
$tested = [];
foreach (['sh', 'bash', 'dash', 'zsh', 'fish', 'tcsh', 'csh'] as $loginShell) {
$path = trim((string) shell_exec('command -v '.escapeshellarg($loginShell).' 2>/dev/null'));
if ($path === '') {
continue;
}
$output = shell_exec('echo "echo remote-ok" | '.escapeshellarg($path).' -c '.escapeshellarg($wrapper).' 2>&1');
$this->assertSame('remote-ok', trim((string) $output), "Login shell {$loginShell} could not run the wrapper.");
$tested[] = $loginShell;
}
$this->assertContains('sh', $tested);
}
/**
* fish and csh/tcsh cannot parse POSIX `if ...; then ...; fi`. A single `sh -c "..."` command
* with only double quotes (it is wrapped in single quotes on the ssh command line) is a simple
* command that every login shell can run.
*/
public function test_remote_shell_is_one_simple_sh_command()
{
$wrapper = (new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'))->invoke(null);
$this->assertMatchesRegularExpression('/^sh -c "[^"\'$`!]*"$/', $wrapper);
}
public function test_generate_ssh_command_accepts_disable_multiplexing_parameter()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');
$parameters = $reflection->getParameters();
// Should have at least 3 parameters: $server, $command, $disableMultiplexing
$this->assertGreaterThanOrEqual(3, count($parameters));
$disableMultiplexingParam = $parameters[2] ?? null;
$this->assertNotNull($disableMultiplexingParam);
$this->assertEquals('disableMultiplexing', $disableMultiplexingParam->getName());
$this->assertTrue($disableMultiplexingParam->isDefaultValueAvailable());
$this->assertFalse($disableMultiplexingParam->getDefaultValue());
}
public function test_disable_multiplexing_parameter_is_boolean_type()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');
$parameters = $reflection->getParameters();
$disableMultiplexingParam = $parameters[2] ?? null;
$this->assertNotNull($disableMultiplexingParam);
$type = $disableMultiplexingParam->getType();
$this->assertNotNull($type);
$this->assertEquals('bool', $type->getName());
}
public function test_instant_remote_process_accepts_disable_multiplexing_parameter()
{
$this->assertTrue(
function_exists('instant_remote_process'),
'instant_remote_process function should exist'
);
$reflection = new \ReflectionFunction('instant_remote_process');
$parameters = $reflection->getParameters();
// Find the disableMultiplexing parameter
$disableMultiplexingParam = null;
foreach ($parameters as $param) {
if ($param->getName() === 'disableMultiplexing') {
$disableMultiplexingParam = $param;
break;
}
}
$this->assertNotNull($disableMultiplexingParam, 'disableMultiplexing parameter should exist');
$this->assertTrue($disableMultiplexingParam->isDefaultValueAvailable());
$this->assertFalse($disableMultiplexingParam->getDefaultValue());
}
public function test_instant_remote_process_disable_multiplexing_is_boolean_type()
{
$reflection = new \ReflectionFunction('instant_remote_process');
$parameters = $reflection->getParameters();
// Find the disableMultiplexing parameter
$disableMultiplexingParam = null;
foreach ($parameters as $param) {
if ($param->getName() === 'disableMultiplexing') {
$disableMultiplexingParam = $param;
break;
}
}
$this->assertNotNull($disableMultiplexingParam);
$type = $disableMultiplexingParam->getType();
$this->assertNotNull($type);
$this->assertEquals('bool', $type->getName());
}
public function test_multiplexing_is_skipped_when_disabled()
{
// This test verifies the logic flow by checking the code path
// When disableMultiplexing is true, the condition `! $disableMultiplexing && self::isMultiplexingEnabled()`
// should evaluate to false, skipping multiplexing entirely
// We verify the condition logic:
// disableMultiplexing = true -> ! true = false -> condition is false -> skip multiplexing
$disableMultiplexing = true;
$this->assertFalse(! $disableMultiplexing, 'When disableMultiplexing is true, negation should be false');
// disableMultiplexing = false -> ! false = true -> condition may proceed
$disableMultiplexing = false;
$this->assertTrue(! $disableMultiplexing, 'When disableMultiplexing is false, negation should be true');
}
}