TenantAtlas/apps/platform/app/Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php
ahmido 9374260ae1 feat: add Exchange PowerShell invocation gate (#498)
## Summary
- Adds the Spec 431 Exchange PowerShell invocation gate and operation registration slice.
- Introduces trusted provider operation start handling and read-only Exchange PowerShell runner abstractions.
- Updates provider capability/readiness evaluation and coverage for Exchange PowerShell invocation safety.

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Feature/TenantConfiguration/Spec431ExchangePowerShellInvocationGateTest.php tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellCommandAllowlistTest.php tests/Unit/Support/TenantConfiguration/Spec431ExchangePowerShellOperationRegistrationTest.php tests/Unit/Verification/ManagedEnvironmentPermissionCapabilityMappingTest.php`
- Result: 80 passed, 685 assertions.

## Product Surface / Ops
- Rendered UI surface changed: N/A.
- Filament/Livewire surface changed: N/A.
- Deployment impact: config/runtime service changes only; no migrations, queues, or assets added.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #498
2026-07-07 15:23:29 +00:00

119 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Support\Providers\ProviderReasonCodes;
use RuntimeException;
final class FakeExchangePowerShellCommandRunner implements ExchangePowerShellCommandRunner
{
public const string SCENARIO_SUCCESS = 'success';
public const string SCENARIO_EMPTY_SUCCESS = 'empty_success';
public const string SCENARIO_AUTH_FAILURE = 'auth_failure';
public const string SCENARIO_AUTHORIZATION_FAILURE = 'authorization_failure';
public const string SCENARIO_TIMEOUT = 'timeout';
public const string SCENARIO_THROTTLED = 'throttled';
public const string SCENARIO_MODULE_UNAVAILABLE = 'module_unavailable';
public const string SCENARIO_COMMAND_UNAVAILABLE = 'command_unavailable';
public const string SCENARIO_MALFORMED_SCALAR = 'malformed_scalar';
public const string SCENARIO_MALFORMED_TEXT = 'malformed_text';
public const string SCENARIO_UNEXPECTED_EXCEPTION = 'unexpected_exception';
/**
* @var list<array<string, mixed>>
*/
public array $calls = [];
/**
* @param list<array<string, mixed>>|null $items
*/
public function __construct(
private readonly string $scenario = self::SCENARIO_SUCCESS,
private readonly ?array $items = null,
) {}
public function run(
ExchangePowerShellCommandContract $commandContract,
ExchangePowerShellInvocationContext $context,
): ExchangePowerShellInvocationResult {
$this->calls[] = [
'operation_run_id' => $context->operationRunId,
'canonical_type' => $context->canonicalType,
'command_name' => $context->commandName,
'runner_mode' => $context->runnerMode,
'parameter_names' => $commandContract->parameterNames(),
'context' => $context->toArray(),
];
return match ($this->scenario) {
self::SCENARIO_EMPTY_SUCCESS => ExchangePowerShellInvocationResult::succeeded([]),
self::SCENARIO_AUTH_FAILURE => ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::ProviderAuthFailed,
failureCode: 'execution_failed_authentication',
message: 'Exchange PowerShell authentication failed.',
),
self::SCENARIO_AUTHORIZATION_FAILURE => ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::ProviderPermissionDenied,
failureCode: 'execution_failed_authorization',
message: 'Exchange PowerShell authorization failed.',
),
self::SCENARIO_TIMEOUT => ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::NetworkUnreachable,
failureCode: 'execution_failed_timeout',
message: 'Exchange PowerShell invocation timed out.',
),
self::SCENARIO_THROTTLED => ExchangePowerShellInvocationResult::failed(
reasonCode: ProviderReasonCodes::RateLimited,
failureCode: 'execution_failed_throttled',
message: 'Exchange PowerShell invocation was rate limited.',
),
self::SCENARIO_MODULE_UNAVAILABLE => ExchangePowerShellInvocationResult::blocked(
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
failureCode: 'execution_blocked_module_unavailable',
message: 'Exchange PowerShell module is unavailable.',
),
self::SCENARIO_COMMAND_UNAVAILABLE => ExchangePowerShellInvocationResult::blocked(
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
failureCode: 'execution_blocked_command_unavailable',
message: 'Exchange PowerShell command is unavailable.',
),
self::SCENARIO_MALFORMED_SCALAR => ExchangePowerShellInvocationResult::succeededPayload(42),
self::SCENARIO_MALFORMED_TEXT => ExchangePowerShellInvocationResult::succeededPayload('raw transcript rejected by shape gate'),
self::SCENARIO_UNEXPECTED_EXCEPTION => throw new RuntimeException('Simulated runner exception with token and secret redaction pressure.'),
default => ExchangePowerShellInvocationResult::succeeded($this->items ?? $this->defaultItems($commandContract->toArray())),
};
}
/**
* @param array<string, mixed> $commandContract
* @return list<array<string, mixed>>
*/
private function defaultItems(array $commandContract): array
{
$canonicalType = (string) ($commandContract['canonical_type'] ?? 'exchange');
$label = (string) ($commandContract['human_label'] ?? 'Exchange object');
return [
[
'id' => 'spec431-'.$canonicalType.'-1',
'sourceId' => 'spec431-'.$canonicalType.'-source-1',
'Guid' => '00000000-0000-4000-8000-000000000431',
'Name' => 'Spec 431 '.$label,
'DisplayName' => 'Spec 431 '.$label,
],
];
}
}