Validation: sail artisan test --filter=Spec433|Spec432|Spec431 --compact; pint --dirty --test; git diff --check Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #500
663 lines
29 KiB
PHP
663 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ManagedEnvironmentMembership;
|
|
use App\Models\ManagedEnvironmentPermission;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Models\User;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\Providers\ProviderOperationStartGate;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandRunner;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationContext;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
|
use App\Services\TenantConfiguration\FakeExchangePowerShellCommandRunner;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Providers\ProviderConsentStatus;
|
|
use App\Support\Providers\ProviderCredentialKind;
|
|
use App\Support\Providers\ProviderCredentialSource;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use App\Support\Providers\ProviderVerificationStatus;
|
|
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
beforeEach(function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
config([
|
|
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false,
|
|
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
|
]);
|
|
});
|
|
|
|
it('Spec431 fake runner invocation creates a completed count-only OperationRun before runner execution', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toHaveCount(1)
|
|
->and($runner->calls[0]['operation_run_id'])->toBe((int) $run->getKey())
|
|
->and($run->type)->toBe(ExchangePowerShellInvocationGate::OPERATION_TYPE)
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($run->summary_counts)->toMatchArray([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 1,
|
|
])
|
|
->and(data_get($run->context, 'provider_connection_id'))->toBe((int) $connection->getKey())
|
|
->and(data_get($run->context, 'provider_identity_resolution_mode'))->toBe('fake_runner_bypass')
|
|
->and(data_get($run->context, 'source_contract.source_contract_state'))->toBe('contract_verified_pending_capture')
|
|
->and(data_get($run->context, 'source_contract.provider_calls_allowed'))->toBeFalse()
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
|
|
$contextJson = json_encode($run->context, JSON_THROW_ON_ERROR);
|
|
|
|
expect($contextJson)
|
|
->not->toContain('raw_provider_payload')
|
|
->not->toContain('raw_shell_stdout')
|
|
->not->toContain('client_secret')
|
|
->not->toContain('access_token');
|
|
});
|
|
|
|
it('Spec431 default feature gate blocks before fake runner execution', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'remoteDomain',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'failure_code'))->toBe('execution_blocked_feature_disabled')
|
|
->and(data_get($run->context, 'feature_gate.enabled'))->toBeFalse();
|
|
});
|
|
|
|
it('Spec431 denies in-scope users missing provider-run capability as forbidden without OperationRun', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
|
$connection = spec431ProviderConnection($environment, seedSupportedCredential: false);
|
|
$runner = spec431FakeRunner();
|
|
|
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
))->toThrow(AuthorizationException::class);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and(OperationRun::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec431 hides environments outside workspace membership without OperationRun', function (): void {
|
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$outsider = User::factory()->create();
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $outsider,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
))->toThrow(NotFoundHttpException::class);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and(OperationRun::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec431 hides environments outside explicit managed-environment entitlement scope', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$allowedEnvironment = ManagedEnvironment::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
]);
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
ManagedEnvironmentMembership::query()
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->where('user_id', (int) $user->getKey())
|
|
->delete();
|
|
|
|
ManagedEnvironmentMembership::query()->create([
|
|
'managed_environment_id' => (int) $allowedEnvironment->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'owner',
|
|
'source' => 'manual',
|
|
]);
|
|
|
|
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
|
|
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
))->toThrow(NotFoundHttpException::class);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and(OperationRun::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec431 rejects provider connections outside the environment scope before runner execution', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$otherEnvironment = ManagedEnvironment::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
]);
|
|
$connection = spec431ProviderConnection($otherEnvironment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
))->toThrow(NotFoundHttpException::class);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and(OperationRun::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec431 provider readiness blocks missing admin consent before runner execution', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment, [
|
|
'consent_status' => ProviderConsentStatus::Required->value,
|
|
'consent_granted_at' => null,
|
|
]);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'reason_code'))->toBe(ProviderReasonCodes::ProviderConsentMissing);
|
|
});
|
|
|
|
it('Spec431 provider readiness blocks missing Exchange permission evidence before fake runner execution', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
|
->and(data_get($run->context, 'provider_capability.status'))->toBe('missing')
|
|
->and(data_get($run->context, 'provider_capability.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation');
|
|
});
|
|
|
|
it('Spec431 non-fake mode requires existing provider identity resolution before runner execution', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_DISABLED,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'reason_code'))->toBeIn([
|
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
|
ProviderReasonCodes::ProviderCredentialMissing,
|
|
]);
|
|
});
|
|
|
|
it('Spec431 rejects invalid contracts and raw mutation commands without runner execution', function (string $canonicalType, ?string $commandName, string $expectedFailure): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: $canonicalType,
|
|
commandName: $commandName,
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'failure_code'))->toBe($expectedFailure);
|
|
|
|
$contextJson = json_encode($run->context, JSON_THROW_ON_ERROR);
|
|
|
|
if (is_string($commandName) && str_starts_with($commandName, 'Set-')) {
|
|
expect($contextJson)->not->toContain($commandName);
|
|
}
|
|
|
|
if (str_contains($canonicalType, 'client_secret')) {
|
|
expect($contextJson)->not->toContain($canonicalType);
|
|
}
|
|
})->with([
|
|
'unsupported target' => ['acceptedDomain', null, 'source_contract_missing'],
|
|
'unsafe target' => ['client_secret_super_token', null, 'source_contract_missing'],
|
|
'mutation command' => ['transportRule', 'Set-TransportRule', 'command_contract_rejected'],
|
|
'wrong allowlisted command' => ['transportRule', 'Get-RemoteDomain', 'command_contract_rejected'],
|
|
]);
|
|
|
|
it('Spec431 rejects unknown command parameters before runner execution', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner();
|
|
$secretParameterName = 'client_secret_super_token';
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'transportRule',
|
|
parameters: [$secretParameterName => 'unexpected'],
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toBe([])
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($run->context, 'failure_code'))->toBe('command_contract_rejected')
|
|
->and(data_get($run->context, 'command_validation.reason_code'))->toBe('unknown_parameter_rejected')
|
|
->and(data_get($run->context, 'command_validation.rejected_parameter_present'))->toBeTrue()
|
|
->and(data_get($run->context, 'command_validation.rejected_parameter'))->toBeNull()
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))
|
|
->not->toContain($secretParameterName)
|
|
->not->toContain('unexpected');
|
|
});
|
|
|
|
it('Spec431 malformed fake-runner output fails safely without raw output promotion', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner(FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT);
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'inboundConnector',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
expect($runner->calls)->toHaveCount(1)
|
|
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
|
|
->and(data_get($run->context, 'failure_code'))->toBe('execution_failed_shape_unsafe')
|
|
->and(data_get($run->context, 'response_shape.safe'))->toBeFalse()
|
|
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('raw transcript rejected');
|
|
});
|
|
|
|
it('Spec431 fake-runner scenarios persist only sanitized lifecycle outcomes', function (
|
|
string $scenario,
|
|
string $expectedOutcome,
|
|
?string $expectedFailureCode,
|
|
?string $expectedReasonCode,
|
|
array $expectedSummaryCounts,
|
|
): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$runner = spec431FakeRunner($scenario);
|
|
|
|
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
actor: $user,
|
|
canonicalType: 'remoteDomain',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
);
|
|
|
|
$failureSummary = is_array($run->failure_summary) ? $run->failure_summary : [];
|
|
$reasonCode = data_get($run->context, 'reason_code') ?? data_get($failureSummary, '0.reason_code');
|
|
$persistedJson = json_encode([
|
|
'context' => $run->context,
|
|
'failure_summary' => $failureSummary,
|
|
'summary_counts' => $run->summary_counts,
|
|
], JSON_THROW_ON_ERROR);
|
|
|
|
expect($runner->calls)->toHaveCount(1)
|
|
->and($run->status)->toBe(OperationRunStatus::Completed->value)
|
|
->and($run->outcome)->toBe($expectedOutcome)
|
|
->and($run->summary_counts)->toMatchArray($expectedSummaryCounts)
|
|
->and(array_keys($run->summary_counts ?? []))->each->toBeIn(['total', 'processed', 'succeeded', 'failed', 'skipped', 'items'])
|
|
->and(data_get($run->context, 'failure_code'))->toBe($expectedFailureCode)
|
|
->and($reasonCode)->toBe($expectedReasonCode)
|
|
->and($persistedJson)
|
|
->not->toContain('raw transcript rejected')
|
|
->not->toContain('Simulated runner exception')
|
|
->not->toContain('access_token')
|
|
->not->toContain('client_secret')
|
|
->not->toContain('refresh_token')
|
|
->not->toContain('password=');
|
|
})->with([
|
|
'empty success' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_EMPTY_SUCCESS,
|
|
OperationRunOutcome::Succeeded->value,
|
|
null,
|
|
null,
|
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'authentication failure' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_AUTH_FAILURE,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_authentication',
|
|
ProviderReasonCodes::ProviderAuthFailed,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'authorization failure' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_AUTHORIZATION_FAILURE,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_authorization',
|
|
ProviderReasonCodes::ProviderPermissionDenied,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'timeout' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_TIMEOUT,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_timeout',
|
|
ProviderReasonCodes::NetworkUnreachable,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'throttled' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_THROTTLED,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_throttled',
|
|
ProviderReasonCodes::RateLimited,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'module unavailable' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_MODULE_UNAVAILABLE,
|
|
OperationRunOutcome::Blocked->value,
|
|
'execution_blocked_module_unavailable',
|
|
ProviderReasonCodes::ProviderBindingUnsupported,
|
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'command unavailable' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_COMMAND_UNAVAILABLE,
|
|
OperationRunOutcome::Blocked->value,
|
|
'execution_blocked_command_unavailable',
|
|
ProviderReasonCodes::ProviderBindingUnsupported,
|
|
['total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'malformed scalar' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_SCALAR,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_shape_unsafe',
|
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'malformed text' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_MALFORMED_TEXT,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_shape_unsafe',
|
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
'unexpected exception' => [
|
|
FakeExchangePowerShellCommandRunner::SCENARIO_UNEXPECTED_EXCEPTION,
|
|
OperationRunOutcome::Failed->value,
|
|
'execution_failed_unexpected_exception',
|
|
ProviderReasonCodes::UnknownError,
|
|
['total' => 1, 'processed' => 1, 'succeeded' => 0, 'failed' => 1, 'skipped' => 0, 'items' => 0],
|
|
],
|
|
]);
|
|
|
|
it('Spec431 direct provider start-gate invocation is blocked as a bypass path', function (): void {
|
|
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
|
|
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec431ProviderConnection($environment);
|
|
$dispatched = 0;
|
|
|
|
$result = app(ProviderOperationStartGate::class)->start(
|
|
tenant: $environment,
|
|
connection: $connection,
|
|
operationType: ExchangePowerShellInvocationGate::OPERATION_TYPE,
|
|
dispatcher: function () use (&$dispatched): void {
|
|
$dispatched++;
|
|
},
|
|
extraContext: [
|
|
'invocation_gate' => 'exchange_powershell_invocation_gate',
|
|
'parameter_names' => ['client_secret_super_token'],
|
|
],
|
|
);
|
|
|
|
expect($dispatched)->toBe(0)
|
|
->and($result->status)->toBe('blocked')
|
|
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
|
->and(data_get($result->run->context, 'reason_code_extension'))->toBe('ext.exchange_powershell_invocation_gate_required')
|
|
->and(json_encode($result->run->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret_super_token')
|
|
->and(file_get_contents(app_path('Providers/AppServiceProvider.php')) ?: '')->not->toContain('Closure::bind')
|
|
->and(file_get_contents(app_path('Services/Providers/ProviderOperationStartGate.php')) ?: '')->not->toContain('startTrusted');
|
|
});
|
|
|
|
it('Spec431 direct runner calls reject raw command strings arrays and unverified parameters by type boundary', function (): void {
|
|
$runner = new FakeExchangePowerShellCommandRunner;
|
|
$context = new ExchangePowerShellInvocationContext(
|
|
operationRunId: 431,
|
|
workspaceId: 1,
|
|
managedEnvironmentId: 1,
|
|
providerConnectionId: 1,
|
|
canonicalType: 'transportRule',
|
|
commandName: 'Get-TransportRule',
|
|
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_FAKE,
|
|
redactionPolicy: ExchangePowerShellInvocationGate::REDACTION_POLICY,
|
|
sourceContractState: 'contract_verified_pending_capture',
|
|
);
|
|
|
|
$contracts = app(ExchangePowerShellCommandContracts::class);
|
|
$contract = $contracts->contractForCanonicalType('transportRule');
|
|
|
|
expect($contract)->toBeArray();
|
|
|
|
$verifiedContract = ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
|
$mutatedContract = [
|
|
...$contract,
|
|
'allowed_parameters' => ['client_secret_super_token'],
|
|
];
|
|
|
|
expect(fn () => $runner->run('Get-TransportRule', $context))->toThrow(TypeError::class)
|
|
->and(fn () => $runner->run(['command_name' => 'Get-TransportRule'], $context))->toThrow(TypeError::class)
|
|
->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray(
|
|
$contract,
|
|
$contracts,
|
|
['client_secret_super_token' => 'unexpected'],
|
|
))->toThrow(InvalidArgumentException::class)
|
|
->and(fn () => ExchangePowerShellCommandContract::fromVerifiedArray(
|
|
$mutatedContract,
|
|
$contracts,
|
|
['client_secret_super_token' => 'unexpected'],
|
|
))->toThrow(InvalidArgumentException::class);
|
|
|
|
$runner->run($verifiedContract, $context);
|
|
|
|
expect($runner->calls[0]['parameter_names'])->toBe([]);
|
|
});
|
|
|
|
it('Spec431 introduces no rendered UI surface routes jobs migrations or evidence promotion', function (): void {
|
|
$runtimeFiles = [
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellInvocationGate.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellCommandRunner.php'),
|
|
app_path('Services/TenantConfiguration/FakeExchangePowerShellCommandRunner.php'),
|
|
app_path('Services/TenantConfiguration/DisabledExchangePowerShellCommandRunner.php'),
|
|
];
|
|
$runtimeSource = collect($runtimeFiles)
|
|
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
|
->implode("\n");
|
|
|
|
expect($runtimeSource)
|
|
->not->toContain('tenant_id')
|
|
->not->toContain('provider_tenant_id')
|
|
->not->toContain('entra_tenant_id')
|
|
->not->toContain('Start-')
|
|
->not->toContain('Set-')
|
|
->not->toContain('New-')
|
|
->not->toContain('Remove-')
|
|
->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
|
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
|
|
->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
|
->and(glob(database_path('migrations/*exchange_powershell*')) ?: [])->toBe([])
|
|
->and(File::exists(app_path('Jobs/TenantConfiguration/InvokeExchangePowerShell.php')))->toBeFalse()
|
|
->and(ManagedEnvironmentPermissionCheckClusters::definitionForKey('provider.exchange_powershell_invocation'))->toBeNull();
|
|
});
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec431ProviderConnection(
|
|
ManagedEnvironment $environment,
|
|
array $overrides = [],
|
|
bool $seedExchangePowerShellPermission = true,
|
|
bool $seedSupportedCredential = true,
|
|
): ProviderConnection {
|
|
$connection = ProviderConnection::factory()
|
|
->dedicated()
|
|
->consentGranted()
|
|
->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'entra_tenant_id' => $environment->providerTenantContext(),
|
|
'is_default' => true,
|
|
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
|
'last_health_check_at' => now(),
|
|
...$overrides,
|
|
]);
|
|
|
|
if ($seedExchangePowerShellPermission) {
|
|
spec431SeedExchangePowerShellPermission($environment, $connection);
|
|
}
|
|
|
|
if ($seedSupportedCredential) {
|
|
spec431Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
}
|
|
|
|
return $connection;
|
|
}
|
|
|
|
function spec431SeedExchangePowerShellPermission(ManagedEnvironment $environment, ProviderConnection $connection, string $status = 'granted'): void
|
|
{
|
|
ManagedEnvironmentPermission::query()->updateOrCreate(
|
|
[
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'permission_key' => 'Exchange.ManageAsApp',
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
],
|
|
[
|
|
'status' => $status,
|
|
'details' => [
|
|
'source' => 'provider_verification',
|
|
'observed_at' => now()->toJSON(),
|
|
'verified_at' => now()->toJSON(),
|
|
'evaluator' => 'exchange_powershell_permission_evidence',
|
|
'evaluator_version' => 'v1',
|
|
'workspace_id' => (int) $connection->workspace_id,
|
|
'managed_environment_id' => (int) $connection->managed_environment_id,
|
|
'provider' => (string) $connection->provider,
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'last_checked_at' => now(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec431Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
|
|
{
|
|
$storedKind = in_array($kind, ProviderCredentialKind::values(), true)
|
|
? $kind
|
|
: ProviderCredentialKind::ClientSecret->value;
|
|
|
|
$credential = ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'type' => $storedKind,
|
|
'credential_kind' => $storedKind,
|
|
'source' => ProviderCredentialSource::DedicatedManual->value,
|
|
'last_rotated_at' => now(),
|
|
'expires_at' => now()->addYear(),
|
|
...$overrides,
|
|
]);
|
|
|
|
if ($storedKind !== $kind) {
|
|
DB::table('provider_credentials')
|
|
->where('id', (int) $credential->getKey())
|
|
->update([
|
|
'type' => $kind,
|
|
'credential_kind' => $kind,
|
|
]);
|
|
}
|
|
|
|
return $credential;
|
|
}
|
|
|
|
function spec431FakeRunner(string $scenario = FakeExchangePowerShellCommandRunner::SCENARIO_SUCCESS): FakeExchangePowerShellCommandRunner
|
|
{
|
|
$runner = new FakeExchangePowerShellCommandRunner($scenario);
|
|
app()->instance(ExchangePowerShellCommandRunner::class, $runner);
|
|
|
|
return $runner;
|
|
}
|