TenantAtlas/apps/platform/tests/Unit/Support/TenantConfiguration/Spec432ExchangePowerShellRuntimeBoundaryTest.php
Ahmed Darrazi 90a411429e
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m23s
feat: add Exchange PowerShell production runner gate
2026-07-07 20:32:36 +02:00

287 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellOutputGuard;
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommand;
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommandBuilder;
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
use App\Services\TenantConfiguration\ExchangePowerShellRuntimeReadinessChecker;
use App\Services\TenantConfiguration\FakeExchangePowerShellProcessExecutor;
it('Spec432 runtime readiness returns sanitized blockers without real PowerShell requirements', function (
ExchangePowerShellRuntimePolicy $policy,
FakeExchangePowerShellProcessExecutor $executor,
string $expectedFailureCode,
): void {
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check($policy);
expect($result->allowed)->toBeFalse()
->and($result->failureCode)->toBe($expectedFailureCode);
})->with([
'feature disabled' => fn (): array => [
spec432RuntimePolicy(invocationEnabled: false),
new FakeExchangePowerShellProcessExecutor,
'runtime_blocked_feature_disabled',
],
'runner disabled' => fn (): array => [
spec432RuntimePolicy(productionRunnerEnabled: false),
new FakeExchangePowerShellProcessExecutor,
'runtime_blocked_runner_disabled',
],
'environment not allowed' => fn (): array => [
spec432RuntimePolicy(allowedEnvironments: ['production']),
new FakeExchangePowerShellProcessExecutor,
'runtime_blocked_environment_not_allowed',
],
'timeout policy missing' => fn (): array => [
spec432RuntimePolicy(checkTimeoutSeconds: 0),
new FakeExchangePowerShellProcessExecutor,
'runtime_blocked_timeout_policy_missing',
],
'temp policy unsafe' => fn (): array => [
spec432RuntimePolicy(tempFilesEnabled: true, tempDirectory: '/tmp/tenantpilot-unsafe'),
new FakeExchangePowerShellProcessExecutor,
'runtime_blocked_temp_policy_unsafe',
],
'process executor missing' => fn (): array => [
spec432RuntimePolicy(),
new FakeExchangePowerShellProcessExecutor(available: false),
'runtime_blocked_process_executor_missing',
],
'powershell missing' => fn (): array => [
spec432RuntimePolicy(),
new FakeExchangePowerShellProcessExecutor(results: [
ExchangePowerShellProcessResult::completed(1, '', 'not found'),
]),
'runtime_blocked_powershell_missing',
],
'module missing' => fn (): array => [
spec432RuntimePolicy(),
new FakeExchangePowerShellProcessExecutor(results: [
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
ExchangePowerShellProcessResult::completed(0, ''),
]),
'runtime_blocked_exchange_module_missing',
],
'module unknown' => fn (): array => [
spec432RuntimePolicy(),
new FakeExchangePowerShellProcessExecutor(results: [
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
ExchangePowerShellProcessResult::completed(1, '', 'module error'),
]),
'runtime_blocked_exchange_module_unknown',
],
]);
it('Spec432 runtime readiness uses fixed non-invasive argument vectors', function (): void {
$executor = new FakeExchangePowerShellProcessExecutor(results: [
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
]);
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
$arguments = json_encode($executor->calls, JSON_THROW_ON_ERROR);
expect($result->allowed)->toBeTrue()
->and($executor->calls)->toHaveCount(2)
->and($arguments)
->toContain('-NoLogo')
->toContain('-NoProfile')
->toContain('-NonInteractive')
->toContain('$PSVersionTable.PSVersion.ToString()')
->toContain('Get-Module -ListAvailable -Name ExchangeOnlineManagement')
->not->toContain('Select-Object')
->not->toContain('| Select-Object')
->not->toContain('Connect-ExchangeOnline')
->not->toContain('Install-Module')
->not->toContain('Invoke-Expression')
->not->toContain('Start-Process')
->not->toContain('Get-ChildItem Env:')
->not->toContain('Read-Host');
});
it('Spec432 runtime readiness catches unexpected executor failures safely', function (): void {
$executor = new class implements ExchangePowerShellProcessExecutor
{
public function available(): bool
{
return true;
}
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult
{
throw new RuntimeException('executor boom with client_secret=unsafe');
}
};
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
expect($result->allowed)->toBeFalse()
->and($result->failureCode)->toBe('runtime_failed_unexpected')
->and(json_encode($result->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret');
});
it('Spec432 command builder creates only fixed argument-vector commands from verified contracts', function (string $canonicalType, string $commandName): void {
$contracts = new ExchangePowerShellCommandContracts;
$verifiedContract = spec432VerifiedContract($canonicalType);
$result = (new ExchangePowerShellProcessCommandBuilder($contracts))->build($verifiedContract, spec432RuntimePolicy());
expect($result->successful)->toBeTrue()
->and($result->command)->toBeInstanceOf(ExchangePowerShellProcessCommand::class)
->and($result->command?->arguments)->toBeArray()
->and($result->command?->arguments[0])->toBe('pwsh')
->and($result->command?->arguments)->toContain('-NoLogo')
->and($result->command?->arguments)->toContain('-NoProfile')
->and($result->command?->arguments)->toContain('-NonInteractive')
->and($result->command?->arguments[5])->toBe($commandName)
->and($result->command?->arguments[5])->not->toContain('Set-')
->and($result->command?->arguments[5])->not->toContain('New-')
->and($result->command?->arguments[5])->not->toContain('Remove-')
->and($result->command?->arguments[5])->not->toContain('|')
->and($result->command?->arguments[5])->not->toContain(';')
->and($result->command?->arguments[5])->not->toContain('$')
->and($result->command?->arguments[5])->not->toContain('>')
->and($result->command?->arguments[5])->not->toContain('ConvertTo-Json')
->and($result->context['command_builder_state'])->toBe('fixed_allowlist_argument_vector');
})->with([
'transport rule' => ['transportRule', 'Get-TransportRule'],
'remote domain' => ['remoteDomain', 'Get-RemoteDomain'],
'inbound connector' => ['inboundConnector', 'Get-InboundConnector'],
]);
it('Spec432 command contracts reject mutation commands shell fragments and unknown parameters before command building', function (mixed $commandName, string $expectedReason): void {
$contracts = new ExchangePowerShellCommandContracts;
expect($contracts->validateCommandName($commandName)['reason_code'])->toBe($expectedReason);
})->with([
'set command' => ['Set-TransportRule', 'mutation_command_rejected'],
'new command' => ['New-TransportRule', 'mutation_command_rejected'],
'remove command' => ['Remove-TransportRule', 'mutation_command_rejected'],
'enable command' => ['Enable-TransportRule', 'mutation_command_rejected'],
'disable command' => ['Disable-TransportRule', 'mutation_command_rejected'],
'update command' => ['Update-TransportRule', 'mutation_command_rejected'],
'start command' => ['Start-TransportRule', 'mutation_command_rejected'],
'stop command' => ['Stop-TransportRule', 'mutation_command_rejected'],
'invoke command' => ['Invoke-Expression', 'unsafe_command_text_rejected'],
'search command' => ['Search-TransportRule', 'mutation_command_rejected'],
'export command' => ['Export-TransportRule', 'mutation_command_rejected'],
'import command' => ['Import-TransportRule', 'mutation_command_rejected'],
'semicolon chain' => ['Get-TransportRule; Get-RemoteDomain', 'unsafe_command_text_rejected'],
'pipe chain' => ['Get-TransportRule | Out-File x', 'unsafe_command_text_rejected'],
'redirection' => ['Get-TransportRule > x', 'unsafe_command_text_rejected'],
'script fragment' => ['Get-TransportRule $(Get-Process)', 'unsafe_command_text_rejected'],
'alias unknown' => ['gtr', 'command_not_allowlisted'],
]);
it('Spec432 verified command contract rejects unknown parameters without exposing parameter values', function (): void {
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType('transportRule');
expect($contract)->toBeArray();
$validation = $contracts->validateInvocation($contract, ['client_secret_super_token' => 'unsafe']);
expect($validation['accepted'])->toBeFalse()
->and($validation['reason_code'])->toBe('unknown_parameter_rejected')
->and(json_encode($validation, JSON_THROW_ON_ERROR))
->not->toContain('unsafe');
});
it('Spec432 output guard accepts structured JSON collections and summarizes without raw payload persistence', function (): void {
$result = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","secret":"raw-output-secret"}]', durationMs: 12),
spec432VerifiedContract('transportRule'),
spec432RuntimePolicy(),
);
expect($result->successful)->toBeTrue()
->and($result->collection)->toHaveCount(1)
->and($result->context['output_state'])->toBe('structured_collection')
->and($result->context['item_count'])->toBe(1);
});
it('Spec432 output guard fails safely for unsafe process outputs', function (
ExchangePowerShellProcessResult $processResult,
string $expectedFailureCode,
): void {
$result = (new ExchangePowerShellOutputGuard)->guard(
$processResult,
spec432VerifiedContract('transportRule'),
spec432RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
);
expect($result->successful)->toBeFalse()
->and($result->failureCode)->toBe($expectedFailureCode)
->and($result->summaryCounts)->toMatchArray([
'total' => 1,
'processed' => 1,
'succeeded' => 0,
'failed' => 1,
'skipped' => 0,
'items' => 0,
])
->and(json_encode($result->context, JSON_THROW_ON_ERROR))
->not->toContain('secret-output');
})->with([
'warning prefix' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'execution_failed_output_shape_unsafe'],
'nonzero with stdout' => [ExchangePowerShellProcessResult::completed(1, '[{"id":"rule-1"}]'), 'execution_failed_nonzero_exit'],
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'execution_failed_timeout'],
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'execution_failed_stdout_oversized'],
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'execution_failed_stderr_oversized'],
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'execution_failed_output_encoding'],
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'execution_failed_shape_unsafe'],
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'execution_failed_shape_unsafe'],
'too many items' => [ExchangePowerShellProcessResult::completed(0, '[{"id":"one"},{"id":"two"}]'), 'execution_failed_item_limit_exceeded'],
'executor exception' => [ExchangePowerShellProcessResult::exception('RuntimeException'), 'execution_failed_unexpected_exception'],
]);
function spec432RuntimePolicy(
bool $invocationEnabled = true,
bool $productionRunnerEnabled = true,
array $allowedEnvironments = [],
int $checkTimeoutSeconds = 5,
int $invocationTimeoutSeconds = 60,
int $stdoutMaxBytes = 524288,
int $stderrMaxBytes = 65536,
int $itemLimit = 1000,
int $lockTtlSeconds = 300,
bool $tempFilesEnabled = false,
?string $tempDirectory = null,
): ExchangePowerShellRuntimePolicy {
return new ExchangePowerShellRuntimePolicy(
invocationEnabled: $invocationEnabled,
productionRunnerEnabled: $productionRunnerEnabled,
allowedEnvironments: $allowedEnvironments !== [] ? $allowedEnvironments : [app()->environment()],
currentEnvironment: app()->environment(),
powershellBinary: 'pwsh',
moduleCheckEnabled: true,
checkTimeoutSeconds: $checkTimeoutSeconds,
invocationTimeoutSeconds: $invocationTimeoutSeconds,
stdoutMaxBytes: $stdoutMaxBytes,
stderrMaxBytes: $stderrMaxBytes,
itemLimit: $itemLimit,
lockTtlSeconds: $lockTtlSeconds,
tempFilesEnabled: $tempFilesEnabled,
tempDirectory: $tempDirectory,
);
}
function spec432VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
{
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec432 command contract.');
}
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
}