Summary: add Spec 434 Exchange PowerShell evidence capture adapter and prerequisite/identity/content-only guards; cap Exchange evidence at content_backed while preserving Graph capture. Validation: php artisan test --filter=Spec434 --compact; ./vendor/bin/pint --dirty --test; git diff --cached --check. Product Surface: N/A - no rendered UI surface changed; no deployment impact. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #501
737 lines
31 KiB
PHP
737 lines
31 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\Models\TenantConfigurationResourceType;
|
|
use App\Models\User;
|
|
use App\Services\TenantConfiguration\CanonicalIdentityResolver;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceCaptureAdapter;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellIdentityEvidenceGate;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\Providers\ProviderConsentStatus;
|
|
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 App\Support\TenantConfiguration\IdentityState;
|
|
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('Spec434 captures accepted Exchange PowerShell output as internal content-backed evidence only', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434TransportRulePayload()],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
$run->refresh();
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
$evidence = TenantConfigurationResourceEvidence::query()->sole();
|
|
|
|
expect($result)
|
|
->toMatchArray([
|
|
'canonical_type' => 'transportRule',
|
|
'outcome' => CaptureOutcome::Captured->value,
|
|
'adapter_outcome' => CaptureOutcome::Captured->value,
|
|
'item_count' => 1,
|
|
'reason_code' => null,
|
|
'source_contract_key' => 'exchange_powershell.transportRule',
|
|
'summary_counts' => [
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 1,
|
|
],
|
|
])
|
|
->and($result['evidence_ids'])->toBe([(int) $evidence->getKey()])
|
|
->and($run->summary_counts)->toBe([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 1,
|
|
])
|
|
->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_identity_state)->toBe(IdentityState::Stable)
|
|
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
|
|
->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked)
|
|
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
|
|
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
|
|
->and($evidence->source_contract_key)->toBe('exchange_powershell.transportRule')
|
|
->and($evidence->source_endpoint)->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE.':Get-TransportRule')
|
|
->and($evidence->source_metadata['capture_adapter'])->toBe('exchange_powershell_evidence_capture_adapter')
|
|
->and($evidence->source_metadata['content_level_maximum'])->toBe('content_backed')
|
|
->and($evidence->source_metadata['customer_claims_allowed'])->toBeFalse()
|
|
->and($evidence->source_metadata['restore_allowed'])->toBeFalse()
|
|
->and($evidence->source_metadata['certification_allowed'])->toBeFalse()
|
|
->and($evidence->normalized_payload['source']['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE)
|
|
->and($evidence->permission_context['provider_connection_id'])->toBe((int) $connection->getKey());
|
|
|
|
$persistedRun = json_encode([
|
|
'context' => $run->context,
|
|
'summary_counts' => $run->summary_counts,
|
|
'failure_summary' => $run->failure_summary,
|
|
], JSON_THROW_ON_ERROR);
|
|
|
|
expect($persistedRun)
|
|
->not->toContain('raw_shell_stdout')
|
|
->not->toContain('raw_shell_stderr')
|
|
->not->toContain('provider_response')
|
|
->not->toContain('client_secret')
|
|
->not->toContain('access_token');
|
|
});
|
|
|
|
it('Spec434 accepts only the included Exchange PowerShell capture targets', function (string $canonicalType): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: $canonicalType,
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434PayloadFor($canonicalType)],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['outcome'])->toBe(CaptureOutcome::Captured->value)
|
|
->and($result['item_count'])->toBe(1)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1);
|
|
})->with([
|
|
'transportRule',
|
|
'remoteDomain',
|
|
'inboundConnector',
|
|
]);
|
|
|
|
it('Spec434 blocks unsupported Exchange and Teams targets before evidence append', function (string $canonicalType): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: $canonicalType,
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[['id' => 'unsupported-1', 'DisplayName' => 'Unsupported']],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and($result['reason_code'])->toBe('unsupported_exchange_capture_type')
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'acceptedDomain',
|
|
'organizationConfig',
|
|
'meetingPolicy',
|
|
]);
|
|
|
|
it('Spec434 blocks missing operation runs through the typed adapter boundary', function (): void {
|
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
|
|
expect(fn () => app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: null,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434TransportRulePayload()],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
))->toThrow(TypeError::class);
|
|
|
|
expect(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 blocks wrong operation type and same-scope mismatches before evidence append', function (string $case): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection);
|
|
|
|
if ($case === 'wrong_operation_type') {
|
|
$run->forceFill(['type' => OperationRunType::TenantConfigurationExchangePowerShellInvocation->value])->save();
|
|
}
|
|
|
|
if ($case === 'wrong_workspace_context') {
|
|
$run->forceFill(['context' => array_replace_recursive($run->context, [
|
|
'target_scope' => ['workspace_id' => (int) $environment->workspace_id + 999],
|
|
])])->save();
|
|
}
|
|
|
|
if ($case === 'wrong_managed_environment_context') {
|
|
$run->forceFill(['context' => array_replace_recursive($run->context, [
|
|
'target_scope' => ['managed_environment_id' => (int) $environment->getKey() + 999],
|
|
])])->save();
|
|
}
|
|
|
|
if ($case === 'wrong_provider_connection_context') {
|
|
$run->forceFill(['context' => array_replace_recursive($run->context, [
|
|
'target_scope' => ['provider_connection_id' => (int) $connection->getKey() + 999],
|
|
])])->save();
|
|
}
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run->refresh(),
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434TransportRulePayload()],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and($result['reason_code'])->toBe($case === 'wrong_operation_type' ? 'operation_type_mismatch' : 'scope_mismatch')
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'wrong_operation_type',
|
|
'wrong_workspace_context',
|
|
'wrong_managed_environment_context',
|
|
'wrong_provider_connection_context',
|
|
]);
|
|
|
|
it('Spec434 blocks cross-scope provider connections before evidence append', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
[, $otherEnvironment] = createMinimalUserWithTenant(role: 'owner');
|
|
$foreignConnection = spec434ReadyProviderConnection($otherEnvironment);
|
|
spec434Credential($foreignConnection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $foreignConnection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $foreignConnection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434TransportRulePayload()],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and($result['reason_code'])->toBe('scope_mismatch')
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 blocks missing readiness and non-supported provider capability before evidence append', function (string $case): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = match ($case) {
|
|
'missing_readiness' => spec434ReadyProviderConnection($environment, seedExchangePowerShellPermission: false),
|
|
'capability_not_supported' => spec434ReadyProviderConnection($environment, [
|
|
'consent_status' => ProviderConsentStatus::Required->value,
|
|
'consent_granted_at' => null,
|
|
]),
|
|
default => throw new InvalidArgumentException('Unsupported Spec434 readiness case.'),
|
|
};
|
|
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[spec434TransportRulePayload()],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and($result['reason_code'])->not->toBeNull()
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'missing_readiness',
|
|
'capability_not_supported',
|
|
]);
|
|
|
|
it('Spec434 blocks runner results that are not accepted structured or empty collections', function (ExchangePowerShellInvocationResult $runnerResult): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: $runnerResult,
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'missing output state' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::succeeded([spec434TransportRulePayload()]),
|
|
'associative payload' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::succeededPayload(
|
|
['Guid' => 'not-a-list'],
|
|
['output_state' => 'structured_collection'],
|
|
),
|
|
'failed runner' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::failed(
|
|
reasonCode: 'provider_failed',
|
|
failureCode: 'runner_failed',
|
|
message: 'Rejected safely.',
|
|
),
|
|
]);
|
|
|
|
it('Spec434 blocks unsafe identity before resource or evidence writes', function (string $canonicalType, array $payload, string $reasonCode): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($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'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED)
|
|
->and($result['reason_code'])->toBe($reasonCode)
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
})->with([
|
|
'transport rule display-name only' => [
|
|
'transportRule',
|
|
['DisplayName' => 'Display names are not stable identifiers'],
|
|
'missing_stable_external_id',
|
|
],
|
|
'remote domain derived-only domain name' => [
|
|
'remoteDomain',
|
|
['DomainName' => 'contoso.example', 'DisplayName' => 'Contoso'],
|
|
'missing_stable_external_id',
|
|
],
|
|
]);
|
|
|
|
it('Spec434 blocks duplicate stable identities before resource or evidence writes', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($environment, $user, $connection);
|
|
|
|
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
canonicalType: 'transportRule',
|
|
runnerResult: ExchangePowerShellInvocationResult::succeeded(
|
|
[
|
|
spec434TransportRulePayload(['DisplayName' => 'Duplicate A']),
|
|
spec434TransportRulePayload(['DisplayName' => 'Duplicate B']),
|
|
],
|
|
context: ['output_state' => 'structured_collection'],
|
|
),
|
|
);
|
|
|
|
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED)
|
|
->and($result['reason_code'])->toBe('duplicate_stable_identity')
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 identity gate detects existing same-scope identity conflict before writer use', function (): void {
|
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
$resourceType = app(ResourceTypeRegistry::class)->findActive('transportRule');
|
|
|
|
expect($resourceType)->toBeInstanceOf(TenantConfigurationResourceType::class);
|
|
|
|
$sourceMetadata = [
|
|
'source_contract_key' => 'exchange_powershell.transportRule',
|
|
'source_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
|
|
];
|
|
$payload = ['DisplayName' => 'Collision without stable id'];
|
|
$identity = app(CanonicalIdentityResolver::class)->resolve($resourceType, $payload, $sourceMetadata);
|
|
|
|
TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'canonical_type' => 'transportRule',
|
|
'canonical_resource_key' => $identity->canonicalResourceKey,
|
|
'source_identity' => $identity->sourceIdentity,
|
|
'secondary_identity_keys' => $identity->secondaryKeys,
|
|
'latest_identity_state' => IdentityState::MissingExternalId->value,
|
|
'latest_claim_state' => ClaimState::ClaimBlocked->value,
|
|
]);
|
|
|
|
$result = app(ExchangePowerShellIdentityEvidenceGate::class)->evaluateCollection(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
resourceType: $resourceType,
|
|
items: [$payload],
|
|
sourceMetadata: $sourceMetadata,
|
|
);
|
|
|
|
$resource = TenantConfigurationResource::query()->sole();
|
|
|
|
expect($result['allowed'])->toBeFalse()
|
|
->and($result['reason_code'])->toBe('identity_conflict')
|
|
->and($resource->refresh()->latest_identity_state)->toBe(IdentityState::IdentityConflict)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 identity gate reports unsupported identity states without writer use', function (): void {
|
|
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
$resourceType = TenantConfigurationResourceType::factory()->create([
|
|
'canonical_type' => 'unsupportedExchangeIdentity',
|
|
]);
|
|
|
|
$result = app(ExchangePowerShellIdentityEvidenceGate::class)->evaluateCollection(
|
|
tenant: $environment,
|
|
providerConnection: $connection,
|
|
resourceType: $resourceType,
|
|
items: [['id' => 'unsupported-identity']],
|
|
sourceMetadata: [],
|
|
);
|
|
|
|
expect($result['allowed'])->toBeFalse()
|
|
->and($result['reason_code'])->toBe('unsupported_identity')
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 empty collections persist only zero summary counts and no durable collection proof', function (): void {
|
|
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
|
$connection = spec434ReadyProviderConnection($environment);
|
|
spec434Credential($connection, ProviderCredentialKind::Certificate->value);
|
|
$run = spec434CaptureRun($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)
|
|
->toMatchArray([
|
|
'outcome' => CaptureOutcome::Captured->value,
|
|
'adapter_outcome' => ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION,
|
|
'item_count' => 0,
|
|
'reason_code' => ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION,
|
|
'summary_counts' => [
|
|
'total' => 0,
|
|
'processed' => 0,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 0,
|
|
],
|
|
])
|
|
->and($run->refresh()->summary_counts)->toBe([
|
|
'total' => 0,
|
|
'processed' => 0,
|
|
'succeeded' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'items' => 0,
|
|
])
|
|
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
|
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('Spec434 implementation has no Graph fallback path, product surface, tenant-id scope, or mini-platform', function (): void {
|
|
$runtimeFiles = [
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php'),
|
|
app_path('Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php'),
|
|
app_path('Services/TenantConfiguration/CoverageEvidenceWriter.php'),
|
|
];
|
|
$runtimeSource = collect($runtimeFiles)
|
|
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
|
->implode("\n");
|
|
|
|
expect(preg_match('/\btenant_id\b/', $runtimeSource))->toBe(0)
|
|
->and($runtimeSource)
|
|
->not->toContain('ProviderGateway')
|
|
->not->toContain('GenericContentEvidenceCaptureService')
|
|
->not->toContain('listPolicies(')
|
|
->not->toContain('/deviceManagement')
|
|
->not->toContain('fake_empty_success')
|
|
->not->toContain('fake_captured')
|
|
->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();
|
|
|
|
$captureOutcomeValues = array_map(
|
|
static fn (CaptureOutcome $outcome): string => $outcome->value,
|
|
CaptureOutcome::cases(),
|
|
);
|
|
|
|
expect($captureOutcomeValues)
|
|
->toContain(CaptureOutcome::Captured->value)
|
|
->not->toContain('fake_captured')
|
|
->not->toContain('fake_empty_success');
|
|
});
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec434ReadyProviderConnection(
|
|
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) {
|
|
spec434SeedExchangePowerShellPermission($environment, $connection);
|
|
}
|
|
|
|
return $connection;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function spec434SeedExchangePowerShellPermission(
|
|
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 spec434Credential(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 spec434CaptureRun(
|
|
ManagedEnvironment $environment,
|
|
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',
|
|
];
|
|
|
|
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 spec434TransportRulePayload(array $overrides = []): array
|
|
{
|
|
return [
|
|
'Guid' => '11111111-2222-3333-4444-555555555555',
|
|
'RuleId' => 'spec434-rule-1',
|
|
'DisplayName' => 'Spec 434 transport rule',
|
|
'Name' => 'Spec 434 transport rule',
|
|
'Priority' => 0,
|
|
'Mode' => 'Enforce',
|
|
'State' => 'Enabled',
|
|
'Enabled' => true,
|
|
'Conditions' => ['SenderDomainIs' => ['contoso.example']],
|
|
'Actions' => ['RedirectMessageTo' => ['security@contoso.example']],
|
|
'WhenChanged' => 'volatile',
|
|
...$overrides,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec434RemoteDomainPayload(): array
|
|
{
|
|
return [
|
|
'Guid' => '22222222-3333-4444-5555-666666666666',
|
|
'RemoteDomainId' => 'spec434-remote-domain-1',
|
|
'Identity' => 'spec434-remote-domain-1',
|
|
'DomainName' => 'contoso.example',
|
|
'DisplayName' => 'Contoso remote domain',
|
|
'IsDefault' => false,
|
|
'AllowedOOFType' => 'External',
|
|
'AutoReplyEnabled' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec434InboundConnectorPayload(): array
|
|
{
|
|
return [
|
|
'Guid' => '33333333-4444-5555-6666-777777777777',
|
|
'ConnectorId' => 'spec434-inbound-connector-1',
|
|
'Identity' => 'spec434-inbound-connector-1',
|
|
'DisplayName' => 'Spec 434 inbound connector',
|
|
'Name' => 'Spec 434 inbound connector',
|
|
'ConnectorType' => 'Partner',
|
|
'Enabled' => true,
|
|
'RequireTls' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec434PayloadFor(string $canonicalType): array
|
|
{
|
|
return match ($canonicalType) {
|
|
'transportRule' => spec434TransportRulePayload(),
|
|
'remoteDomain' => spec434RemoteDomainPayload(),
|
|
'inboundConnector' => spec434InboundConnectorPayload(),
|
|
default => throw new InvalidArgumentException('Unsupported Spec434 payload type.'),
|
|
};
|
|
}
|