Summary: Wire Exchange PowerShell evidence capture through Spec 435 structured envelopes, target normalizers, and hash builder for transportRule, remoteDomain, and inboundConnector; preserve append-only internal content-backed evidence with fail-closed readiness, output, identity, and redaction behavior; add Spec 436 artifacts and focused coverage. Validation: php artisan test --filter=Spec436 --compact PASS, 28 tests / 307 assertions; git diff --cached --check PASS before commit. Product/Ops: Livewire v4 unchanged; provider registration unchanged under apps/platform/bootstrap/providers.php; global search unchanged; no destructive/high-impact actions; no assets/browser/UI/deployment impact. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #503
727 lines
32 KiB
PHP
727 lines
32 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
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\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceCaptureAdapter;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellHashInputBuilder;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Providers\ProviderCredentialKind;
|
|
use App\Support\Providers\ProviderCredentialSource;
|
|
use App\Support\Providers\ProviderVerificationStatus;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\ClaimState;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
beforeEach(function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
config([
|
|
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
|
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
|
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
|
'tenantpilot.exchange_powershell.permission_evidence.currentness_days' => 30,
|
|
]);
|
|
});
|
|
|
|
it('Spec436 appends content-backed Exchange PowerShell evidence through target normalizers and hash builder', function (
|
|
string $canonicalType,
|
|
string $commandName,
|
|
string $normalizerVersion,
|
|
array $payload,
|
|
): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: $canonicalType,
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[$payload],
|
|
context: ['output_state' => 'structured_collection', 'runner_mode' => 'fake'],
|
|
),
|
|
);
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
$expectedHash = app(ExchangePowerShellHashInputBuilder::class)->hash(
|
|
app(ExchangePowerShellHashInputBuilder::class)->build(
|
|
resourceType: $canonicalType,
|
|
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
|
|
commandContractName: $commandName,
|
|
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
|
|
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
|
|
normalizerVersion: $normalizerVersion,
|
|
normalizedPayload: $evidence->normalized_payload,
|
|
),
|
|
);
|
|
|
|
expect($result)
|
|
->toMatchArray([
|
|
'canonical_type' => $canonicalType,
|
|
'outcome' => CaptureOutcome::Captured->value,
|
|
'adapter_outcome' => CaptureOutcome::Captured->value,
|
|
'item_count' => 1,
|
|
'reason_code' => null,
|
|
'source_contract_key' => 'exchange_powershell.'.$canonicalType,
|
|
])
|
|
->and($result['evidence_ids'])->toBe([(int) $evidence->getKey()])
|
|
->and($resource->workspace_id)->toBe((int) $environment->workspace_id)
|
|
->and($resource->managed_environment_id)->toBe((int) $environment->getKey())
|
|
->and($resource->provider_connection_id)->toBe((int) $connection->getKey())
|
|
->and($resource->latest_evidence_id)->toBe((int) $evidence->getKey())
|
|
->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
|
|
->and($evidence->operation_run_id)->toBe((int) $run->getKey())
|
|
->and($evidence->workspace_id)->toBe((int) $environment->workspace_id)
|
|
->and($evidence->managed_environment_id)->toBe((int) $environment->getKey())
|
|
->and($evidence->provider_connection_id)->toBe((int) $connection->getKey())
|
|
->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked)
|
|
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
|
->and($evidence->source_endpoint)->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE.':'.$commandName)
|
|
->and($evidence->raw_payload)->toMatchArray($payload)
|
|
->and($evidence->normalized_payload['canonical_type'])->toBe($canonicalType)
|
|
->and($evidence->normalized_payload['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE)
|
|
->and($evidence->normalized_payload['payload_shape_version'])->toBe(ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION)
|
|
->and($evidence->normalized_payload['normalizer_version'])->toBe($normalizerVersion)
|
|
->and($evidence->normalized_payload['source']['source_contract_key'])->toBe('exchange_powershell.'.$canonicalType)
|
|
->and($evidence->normalized_payload['source']['command_contract_name'])->toBe($commandName)
|
|
->and($evidence->payload_hash)->toBe($expectedHash)
|
|
->and($evidence->permission_context['provider_connection_id'])->toBe((int) $connection->getKey());
|
|
|
|
$runJson = json_encode([
|
|
'context' => $run->refresh()->context,
|
|
'summary_counts' => $run->summary_counts,
|
|
'failure_summary' => $run->failure_summary,
|
|
], JSON_THROW_ON_ERROR);
|
|
|
|
expect($runJson)
|
|
->not->toContain('raw_stdout')
|
|
->not->toContain('raw_stderr')
|
|
->not->toContain('normalizer_preview')
|
|
->not->toContain('hash_preview')
|
|
->not->toContain('client_secret')
|
|
->not->toContain('access_token');
|
|
})->with([
|
|
'transportRule' => [
|
|
'transportRule',
|
|
'Get-TransportRule',
|
|
'exchange-transport-rule-readiness-v1',
|
|
spec436TransportRulePayload(),
|
|
],
|
|
'remoteDomain' => [
|
|
'remoteDomain',
|
|
'Get-RemoteDomain',
|
|
'exchange-remote-domain-readiness-v1',
|
|
spec436RemoteDomainPayload(),
|
|
],
|
|
'inboundConnector' => [
|
|
'inboundConnector',
|
|
'Get-InboundConnector',
|
|
'exchange-inbound-connector-readiness-v1',
|
|
spec436InboundConnectorPayload(),
|
|
],
|
|
]);
|
|
|
|
it('Spec436 blocks unsafe target-normalizer identity states before writer use', function (
|
|
string $canonicalType,
|
|
array $items,
|
|
string $expectedReasonCode,
|
|
): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: $canonicalType,
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
$items,
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED)
|
|
->and($result['reason_code'])->toBe($expectedReasonCode)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'remoteDomain domain-only' => [
|
|
'remoteDomain',
|
|
[['DomainName' => 'contoso.example']],
|
|
'derived_identity_blocked',
|
|
],
|
|
'remoteDomain display-only' => [
|
|
'remoteDomain',
|
|
[['DisplayName' => 'Contoso remote domain']],
|
|
'display_name_only',
|
|
],
|
|
'remoteDomain duplicate identity' => [
|
|
'remoteDomain',
|
|
[
|
|
spec436RemoteDomainPayload(['Guid' => 'duplicate', 'RemoteDomainId' => 'duplicate', 'Identity' => 'duplicate']),
|
|
spec436RemoteDomainPayload(['Guid' => 'duplicate', 'RemoteDomainId' => 'duplicate', 'Identity' => 'duplicate']),
|
|
],
|
|
'duplicate_stable_identity',
|
|
],
|
|
'remoteDomain conflicting aliases' => [
|
|
'remoteDomain',
|
|
[spec436RemoteDomainPayload(['Guid' => 'guid-a', 'RemoteDomainId' => 'guid-b', 'Identity' => 'guid-a'])],
|
|
'identity_conflict',
|
|
],
|
|
'inboundConnector missing identity' => [
|
|
'inboundConnector',
|
|
[['ConnectorType' => 'Partner', 'Enabled' => true]],
|
|
'missing_stable_external_id',
|
|
],
|
|
]);
|
|
|
|
it('Spec436 keeps protected inbound connector configuration out of OperationRun while persisting raw payload only in evidence', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: ['inboundConnector']);
|
|
|
|
app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'inboundConnector',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec436InboundConnectorPayload([
|
|
'SenderIPAddresses' => ['203.0.113.10'],
|
|
'SmartHosts' => ['mail.contoso.example'],
|
|
'TlsSenderCertificateName' => 'CN=secret-cert',
|
|
'Comment' => 'sensitive routing note',
|
|
])],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
$runJson = json_encode([
|
|
'context' => $run->refresh()->context,
|
|
'summary_counts' => $run->summary_counts,
|
|
'failure_summary' => $run->failure_summary,
|
|
], JSON_THROW_ON_ERROR);
|
|
$normalizedJson = json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR);
|
|
|
|
expect($evidence->raw_payload['SenderIPAddresses'])->toBe(['203.0.113.10'])
|
|
->and(data_get($evidence->normalized_payload, 'routing.sender_ip_addresses.value'))->toBe('[redacted]')
|
|
->and(data_get($evidence->normalized_payload, 'routing.smart_hosts.value'))->toBe('[redacted]')
|
|
->and(data_get($evidence->normalized_payload, 'routing.tls_sender_certificate_name.value'))->toBe('[redacted]')
|
|
->and(data_get($evidence->normalized_payload, 'routing.comment.value'))->toBe('[redacted]')
|
|
->and($runJson)
|
|
->not->toContain('203.0.113.10')
|
|
->not->toContain('mail.contoso.example')
|
|
->not->toContain('secret-cert')
|
|
->not->toContain('sensitive routing note')
|
|
->and($normalizedJson)
|
|
->not->toContain('203.0.113.10')
|
|
->not->toContain('mail.contoso.example')
|
|
->not->toContain('secret-cert')
|
|
->not->toContain('sensitive routing note');
|
|
});
|
|
|
|
it('Spec436 appends evidence without mutating prior payloads and updates the latest pointer', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec436CaptureRun($environment, $user, $connection);
|
|
$adapter = app(ExchangePowerShellEvidenceCaptureAdapter::class);
|
|
|
|
$adapter->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec436TransportRulePayload(['Enabled' => true, 'WhenChanged' => '2026-07-08T10:00:00Z'])],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
$firstEvidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
$firstHash = $firstEvidence->payload_hash;
|
|
$firstPayload = $firstEvidence->normalized_payload;
|
|
|
|
$adapter->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec436TransportRulePayload(['Enabled' => false, 'WhenChanged' => '2026-07-09T10:00:00Z'])],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
$evidenceRows = TenantConfigurationResourceEvidence::query()->orderBy('id')->get();
|
|
|
|
expect($evidenceRows)->toHaveCount(2)
|
|
->and($resource->latest_evidence_id)->toBe((int) $evidenceRows[1]->getKey())
|
|
->and($evidenceRows[0]->payload_hash)->toBe($firstHash)
|
|
->and($evidenceRows[0]->normalized_payload)->toBe($firstPayload)
|
|
->and($evidenceRows[1]->payload_hash)->not->toBe($firstHash);
|
|
});
|
|
|
|
it('Spec436 treats empty collections as zero item summaries without fake evidence', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec436CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[],
|
|
context: ['output_state' => 'empty_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION)
|
|
->and($result['outcome'])->toBe(CaptureOutcome::Captured->value)
|
|
->and($result['item_count'])->toBe(0)
|
|
->and($result['summary_counts']['total'])->toBe(0)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec436 readiness and output failures fail closed with no evidence', function (
|
|
string $case,
|
|
callable $arrange,
|
|
ExchangePowerShellInvocationResult $runnerResult,
|
|
): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec436ReadyProviderConnection($environment);
|
|
$arrange($environment, $connection);
|
|
$run = spec436CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: $runnerResult,
|
|
);
|
|
|
|
expect($case)->not->toBe('')
|
|
->and($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'missing credential' => [
|
|
'missing credential',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
|
|
],
|
|
'client secret credential' => [
|
|
'client secret credential',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::ClientSecret->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
|
|
],
|
|
'stale permission evidence' => [
|
|
'stale permission evidence',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
spec436SeedExchangePowerShellPermission($environment, $connection, [
|
|
'last_checked_at' => now()->subDays(31),
|
|
'details' => [
|
|
'observed_at' => now()->subDays(31)->toJSON(),
|
|
'verified_at' => now()->subDays(31)->toJSON(),
|
|
],
|
|
]);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
|
|
],
|
|
'missing permission evidence' => [
|
|
'missing permission evidence',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
ManagedEnvironmentPermission::query()
|
|
->where('managed_environment_id', $environment->getKey())
|
|
->where('permission_key', 'Exchange.ManageAsApp')
|
|
->delete();
|
|
},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
|
|
],
|
|
'wrong-scope permission evidence' => [
|
|
'wrong-scope permission evidence',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
spec436SeedExchangePowerShellPermission($environment, $connection, [
|
|
'details' => [
|
|
'provider_connection_id' => 999999,
|
|
],
|
|
]);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
|
|
],
|
|
'warning-prefixed output' => [
|
|
'warning-prefixed output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'warning_prefixed']),
|
|
],
|
|
'authentication failure' => [
|
|
'authentication failure',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'authentication_failed',
|
|
failureCode: 'authentication_failed',
|
|
message: 'Authentication failed safely.',
|
|
context: ['output_state' => 'authentication_failed'],
|
|
),
|
|
],
|
|
'authorization permission failure' => [
|
|
'authorization permission failure',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'permission_denied',
|
|
failureCode: 'permission_denied',
|
|
message: 'Permission denied safely.',
|
|
context: ['output_state' => 'permission_denied'],
|
|
),
|
|
],
|
|
'source unavailable' => [
|
|
'source unavailable',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'source_unavailable',
|
|
failureCode: 'source_unavailable',
|
|
message: 'Source unavailable safely.',
|
|
context: ['output_state' => 'source_unavailable'],
|
|
),
|
|
],
|
|
'malformed output' => [
|
|
'malformed output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeededPayload('not-json', context: ['output_state' => 'malformed_json_collection']),
|
|
],
|
|
'scalar output' => [
|
|
'scalar output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeededPayload(42, context: ['output_state' => 'scalar_output']),
|
|
],
|
|
'binary output' => [
|
|
'binary output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeededPayload([], context: ['output_state' => 'non_utf8_or_binary']),
|
|
],
|
|
'oversized output' => [
|
|
'oversized output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::succeededPayload([], context: ['output_state' => 'stdout_oversized']),
|
|
],
|
|
'non-zero exit' => [
|
|
'non-zero exit',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'provider_failed',
|
|
failureCode: 'nonzero_exit',
|
|
message: 'Non-zero exit safely blocked.',
|
|
context: ['output_state' => 'nonzero_exit'],
|
|
),
|
|
],
|
|
'partial output' => [
|
|
'partial output',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'partial_output',
|
|
failureCode: 'partial_output',
|
|
message: 'Partial output safely blocked.',
|
|
context: ['output_state' => 'partial_output'],
|
|
),
|
|
],
|
|
'runner timeout' => [
|
|
'runner timeout',
|
|
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
|
|
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
},
|
|
ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'provider_failed',
|
|
failureCode: 'timeout',
|
|
message: 'Timed out safely.',
|
|
context: ['output_state' => 'timeout'],
|
|
),
|
|
],
|
|
]);
|
|
|
|
it('Spec436 introduces no generic promotion path product surface trigger tenant-id ownership or mini-platform', function (): void {
|
|
$adapterSource = file_get_contents(app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php')) ?: '';
|
|
$runtimeFiles = [
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php'),
|
|
app_path('Services/TenantConfiguration/ExchangeTransportRuleEvidenceNormalizer.php'),
|
|
app_path('Services/TenantConfiguration/ExchangeRemoteDomainEvidenceNormalizer.php'),
|
|
app_path('Services/TenantConfiguration/ExchangeInboundConnectorEvidenceNormalizer.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php'),
|
|
];
|
|
$runtimeSource = collect($runtimeFiles)
|
|
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
|
->implode("\n");
|
|
$captureOutcomeValues = array_map(
|
|
static fn (CaptureOutcome $outcome): string => $outcome->value,
|
|
CaptureOutcome::cases(),
|
|
);
|
|
|
|
expect($adapterSource)
|
|
->not->toContain('GenericPayloadNormalizer')
|
|
->not->toContain('ExchangeTeamsComparablePayloadNormalizer')
|
|
->and($runtimeSource)
|
|
->not->toContain('ProviderGateway')
|
|
->not->toContain('fake_empty_success')
|
|
->not->toContain('customer_ready')
|
|
->not->toContain('restore_ready')
|
|
->and(preg_match('/\btenant_id\b/', $runtimeSource))->toBe(0)
|
|
->and(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
|
|
->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse()
|
|
->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
|
->and(glob(app_path('Livewire/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
|
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
|
|
->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
|
->and(glob(database_path('migrations/*exchange*evidence*')) ?: [])->toBe([])
|
|
->and(glob(database_path('migrations/*tenant*configuration*exchange*')) ?: [])->toBe([])
|
|
->and(File::exists(app_path('Jobs/TenantConfiguration/CaptureExchangePowerShellEvidenceJob.php')))->toBeFalse()
|
|
->and(File::exists(app_path('Console/Commands/CaptureExchangePowerShellEvidence.php')))->toBeFalse()
|
|
->and($captureOutcomeValues)
|
|
->toContain(CaptureOutcome::Captured->value)
|
|
->not->toContain('customer_ready')
|
|
->not->toContain('certified')
|
|
->not->toContain('renderable')
|
|
->not->toContain('comparable');
|
|
});
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec436ReadyProviderConnection(
|
|
ManagedEnvironment $environment,
|
|
array $overrides = [],
|
|
bool $seedExchangePowerShellPermission = 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) {
|
|
spec436SeedExchangePowerShellPermission($environment, $connection);
|
|
}
|
|
|
|
return $connection;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec436SeedExchangePowerShellPermission(
|
|
ManagedEnvironment $environment,
|
|
ProviderConnection $connection,
|
|
array $overrides = [],
|
|
): ManagedEnvironmentPermission {
|
|
$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(),
|
|
];
|
|
|
|
if (is_array($overrides['details'] ?? null)) {
|
|
$details = array_replace($details, $overrides['details']);
|
|
}
|
|
|
|
unset($overrides['details']);
|
|
|
|
return ManagedEnvironmentPermission::query()->updateOrCreate(
|
|
[
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'permission_key' => 'Exchange.ManageAsApp',
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
],
|
|
[
|
|
'status' => 'granted',
|
|
'details' => $details,
|
|
'last_checked_at' => now(),
|
|
...$overrides,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec436Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
|
|
{
|
|
return ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'type' => $kind,
|
|
'credential_kind' => $kind,
|
|
'source' => ProviderCredentialSource::DedicatedManual->value,
|
|
'last_rotated_at' => now(),
|
|
'expires_at' => now()->addYear(),
|
|
...$overrides,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @param list<string> $resourceTypes
|
|
*/
|
|
function spec436CaptureRun(
|
|
ManagedEnvironment $environment,
|
|
App\Models\User $user,
|
|
ProviderConnection $connection,
|
|
array $overrides = [],
|
|
array $resourceTypes = ['transportRule'],
|
|
): OperationRun {
|
|
$context = [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => $resourceTypes,
|
|
'required_capability' => 'exchange_powershell_invoke',
|
|
'runner_mode' => 'fake',
|
|
];
|
|
|
|
if (is_array($overrides['context'] ?? null)) {
|
|
$context = array_replace_recursive($context, $overrides['context']);
|
|
}
|
|
|
|
unset($overrides['context']);
|
|
|
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Queued->value,
|
|
'outcome' => OperationRunOutcome::Pending->value,
|
|
'context' => $context,
|
|
...$overrides,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec436TransportRulePayload(array $overrides = []): array
|
|
{
|
|
return [
|
|
'RuleId' => 'spec436-rule-1',
|
|
'Guid' => 'spec436-rule-1',
|
|
'DisplayName' => 'Spec 436 transport rule',
|
|
'Name' => 'Spec 436 transport rule',
|
|
'Priority' => 0,
|
|
'Mode' => 'Enforce',
|
|
'State' => 'Enabled',
|
|
'Enabled' => true,
|
|
'Conditions' => ['SenderDomainIs' => ['contoso.example']],
|
|
'Actions' => ['RedirectMessageTo' => ['security@contoso.example']],
|
|
'WhenChanged' => 'volatile',
|
|
...$overrides,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec436RemoteDomainPayload(array $overrides = []): array
|
|
{
|
|
return [
|
|
'RemoteDomainId' => 'spec436-remote-domain-1',
|
|
'Guid' => 'spec436-remote-domain-1',
|
|
'Identity' => 'spec436-remote-domain-1',
|
|
'DomainName' => 'contoso.example',
|
|
'DisplayName' => 'Contoso remote domain',
|
|
'IsDefault' => false,
|
|
'AllowedOOFType' => 'External',
|
|
'AutoReplyEnabled' => true,
|
|
...$overrides,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec436InboundConnectorPayload(array $overrides = []): array
|
|
{
|
|
return [
|
|
'ConnectorId' => 'spec436-inbound-connector-1',
|
|
'Guid' => 'spec436-inbound-connector-1',
|
|
'Identity' => 'spec436-inbound-connector-1',
|
|
'DisplayName' => 'Spec 436 inbound connector',
|
|
'Name' => 'Spec 436 inbound connector',
|
|
'ConnectorType' => 'Partner',
|
|
'Enabled' => true,
|
|
'RequireTls' => true,
|
|
...$overrides,
|
|
];
|
|
}
|