TenantAtlas/apps/platform/tests/Feature/Baselines/Support/FakeCompareStrategy.php
2026-04-13 23:09:11 +02:00

454 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use App\Models\Tenant;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\Compare\CompareFindingCandidate;
use App\Support\Baselines\Compare\CompareOrchestrationContext;
use App\Support\Baselines\Compare\CompareState;
use App\Support\Baselines\Compare\CompareStrategy;
use App\Support\Baselines\Compare\CompareStrategyCapability;
use App\Support\Baselines\Compare\CompareStrategyKey;
use App\Support\Baselines\Compare\CompareSubjectIdentity;
use App\Support\Baselines\Compare\CompareSubjectProjection;
use App\Support\Baselines\Compare\CompareSubjectResult;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\GovernanceSubjectType;
final class FakeCompareStrategy implements CompareStrategy
{
public function key(): CompareStrategyKey
{
return CompareStrategyKey::from('future_control');
}
public function capabilities(): array
{
return [
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
subjectTypeKeys: ['conditionalAccessPolicy'],
),
];
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
$subjectResults = [];
foreach ($baselineItems as $key => $baselineItem) {
$currentItem = $currentItems[$key] ?? null;
if (! is_array($currentItem)) {
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: $baselineItem,
currentItem: null,
currentEvidence: null,
changeType: 'missing_policy',
severity: 'high',
);
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: (string) $baselineItem['policy_type'],
subjectKey: (string) $baselineItem['subject_key'],
externalSubjectId: (string) $baselineItem['subject_external_id'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
trustLevel: 'unusable',
evidenceQuality: 'missing',
);
continue;
}
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: $baselineItem,
currentItem: $currentItem,
currentEvidence: $currentEvidence,
changeType: 'different_version',
severity: 'medium',
);
continue;
}
$subjectResults[] = new CompareSubjectResult(
subjectIdentity: $this->identity(
policyType: (string) $baselineItem['policy_type'],
externalSubjectId: (string) $baselineItem['subject_external_id'],
subjectKey: (string) $baselineItem['subject_key'],
),
projection: $this->projection(
policyType: (string) $baselineItem['policy_type'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
),
baselineAvailability: 'available',
currentStateAvailability: 'available',
compareState: CompareState::NoDrift,
trustLevel: 'trustworthy',
evidenceQuality: $currentEvidence->fidelity,
);
}
foreach ($currentItems as $key => $currentItem) {
if (array_key_exists($key, $baselineItems)) {
continue;
}
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
if (! $currentEvidence instanceof ResolvedEvidence) {
$subjectResults[] = $this->gapResult(
policyType: (string) $currentItem['policy_type'],
subjectKey: (string) $currentItem['subject_key'],
externalSubjectId: (string) $currentItem['subject_external_id'],
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
compareState: CompareState::Incomplete,
reasonCode: 'missing_current',
baselineAvailability: 'missing',
currentStateAvailability: 'unknown',
trustLevel: 'unusable',
evidenceQuality: 'missing',
);
continue;
}
$subjectResults[] = $this->driftResult(
context: $context,
baselineItem: null,
currentItem: $currentItem,
currentEvidence: $currentEvidence,
changeType: 'unexpected_policy',
severity: 'low',
);
}
return [
'subject_results' => $subjectResults,
'diagnostics' => [
'strategy_family' => 'future_control',
'state_counts' => [
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
],
],
];
}
/**
* @param array<string, mixed> $meta
*/
private function metaFingerprint(array $meta): string
{
unset($meta['display_name'], $meta['category'], $meta['platform']);
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* @param array<string, mixed> $value
* @return array<string, mixed>
*/
private function sortRecursive(array $value): array
{
foreach ($value as $key => $nestedValue) {
if (! is_array($nestedValue)) {
continue;
}
$value[$key] = $this->sortRecursive($nestedValue);
}
ksort($value, SORT_STRING);
return $value;
}
private function driftResult(
CompareOrchestrationContext $context,
?array $baselineItem,
?array $currentItem,
?ResolvedEvidence $currentEvidence,
string $changeType,
string $severity,
): CompareSubjectResult {
$source = $baselineItem ?? $currentItem ?? [];
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
$baselineProvenance = EvidenceProvenance::build(
fidelity: EvidenceProvenance::FidelityMeta,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: null,
);
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
fidelity: $fidelity,
source: EvidenceProvenance::SourceInventory,
observedAt: null,
observedOperationRunId: $context->inventorySyncRunId(),
);
return new CompareSubjectResult(
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
projection: $this->projection($policyType, $operatorLabel),
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
compareState: CompareState::Drift,
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
evidenceQuality: $fidelity,
severityRecommendation: $severity,
findingCandidate: new CompareFindingCandidate(
changeType: $changeType,
severity: $severity,
fingerprintBasis: [
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'change_type' => $changeType,
],
evidencePayload: [
'change_type' => $changeType,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'display_name' => $operatorLabel,
'summary' => ['kind' => 'control_snapshot'],
'baseline' => [
'hash' => $baselineItem['baseline_hash'] ?? null,
'provenance' => $baselineProvenance,
],
'current' => [
'hash' => $currentEvidence?->hash,
'provenance' => $currentProvenance,
],
'fidelity' => $fidelity,
'provenance' => [
'baseline_profile_id' => $context->baselineProfileId,
'baseline_snapshot_id' => $context->baselineSnapshotId,
'compare_operation_run_id' => $context->operationRunId,
'inventory_sync_run_id' => $context->inventorySyncRunId(),
],
],
),
diagnostics: [
'strategy_key' => $this->key()->value,
],
);
}
private function gapResult(
string $policyType,
string $subjectKey,
string $externalSubjectId,
string $operatorLabel,
CompareState $compareState,
string $reasonCode,
string $baselineAvailability,
string $currentStateAvailability,
string $trustLevel,
string $evidenceQuality,
): CompareSubjectResult {
return new CompareSubjectResult(
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
projection: $this->projection($policyType, $operatorLabel),
baselineAvailability: $baselineAvailability,
currentStateAvailability: $currentStateAvailability,
compareState: $compareState,
trustLevel: $trustLevel,
evidenceQuality: $evidenceQuality,
diagnostics: [
'reason_code' => $reasonCode,
'gap_record' => [
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_class' => SubjectClass::Derived->value,
'resolution_path' => ResolutionPath::Derived->value,
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
'structural' => false,
'retryable' => $reasonCode === 'missing_current',
'reason_code' => $reasonCode,
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
],
],
);
}
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
{
return new CompareSubjectIdentity(
domainKey: GovernanceDomainKey::Entra->value,
subjectClass: GovernanceSubjectClass::Control->value,
subjectTypeKey: $policyType,
externalSubjectId: $externalSubjectId,
subjectKey: $subjectKey,
);
}
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
{
return new CompareSubjectProjection(
platformSubjectClass: 'control',
domainKey: GovernanceDomainKey::Entra->value,
subjectTypeKey: $policyType,
operatorLabel: $operatorLabel,
summaryKind: 'control_snapshot',
);
}
}
final class FailingCompareStrategy implements CompareStrategy
{
public function key(): CompareStrategyKey
{
return CompareStrategyKey::from('failing_control');
}
public function capabilities(): array
{
return [
new CompareStrategyCapability(
strategyKey: $this->key(),
domainKeys: [GovernanceDomainKey::Entra->value],
subjectClasses: [GovernanceSubjectClass::Control->value],
subjectTypeKeys: ['conditionalAccessPolicy'],
),
];
}
public function compare(
CompareOrchestrationContext $context,
Tenant $tenant,
array $baselineItems,
array $currentItems,
array $resolvedCurrentEvidence,
array $severityMapping,
): array {
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
}
}
final class FakeGovernanceSubjectTaxonomyRegistry
{
private readonly GovernanceSubjectTaxonomyRegistry $inner;
public function __construct()
{
$this->inner = new GovernanceSubjectTaxonomyRegistry;
}
public function all(): array
{
return array_values(array_merge($this->inner->all(), [
new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Entra,
subjectClass: GovernanceSubjectClass::Control,
subjectTypeKey: 'conditionalAccessPolicy',
label: 'Conditional Access Policy',
description: 'Synthetic test-only future domain control',
captureSupported: true,
compareSupported: true,
inventorySupported: true,
active: true,
supportMode: 'supported',
legacyBucket: null,
),
]));
}
public function active(): array
{
return array_values(array_filter(
$this->all(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
));
}
public function activeLegacyBucketKeys(string $legacyBucket): array
{
$subjectTypes = array_filter(
$this->active(),
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
);
$keys = array_map(
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
$subjectTypes,
);
sort($keys, SORT_STRING);
return array_values(array_unique($keys));
}
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
{
foreach ($this->all() as $subjectType) {
if ($subjectType->domainKey->value !== trim($domainKey)) {
continue;
}
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
continue;
}
return $subjectType;
}
return null;
}
public function isKnownDomain(string $domainKey): bool
{
return $this->inner->isKnownDomain($domainKey);
}
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
{
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
}
public function supportsFilters(string $domainKey, string $subjectClass): bool
{
return $this->inner->supportsFilters($domainKey, $subjectClass);
}
public function groupLabel(string $domainKey, string $subjectClass): string
{
return $this->inner->groupLabel($domainKey, $subjectClass);
}
}