TenantAtlas/apps/platform/tests/Unit/Baselines/CompareSubjectResultContractTest.php
ahmido d644265d30 Spec 203: extract baseline compare strategy (#233)
## Summary
- extract baseline compare orchestration behind an explicit strategy contract and registry
- preserve the current Intune compare path through a dedicated `IntuneCompareStrategy`
- harden compare launch and review surfaces for mixed, unsupported, incomplete, and strategy-failure truth
- add Spec 203 artifacts, focused regression coverage, and future-domain strategy proof tests

## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/CompareStrategyRegistryTest.php tests/Unit/Baselines/CompareSubjectResultContractTest.php tests/Feature/Baselines/BaselineCompareStrategySelectionTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- no new Filament panel/provider registration changes
- no global-search resource changes
- no new asset registration or deployment step changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #233
2026-04-13 21:17:04 +00:00

122 lines
5.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Support\Baselines\Compare\CompareFindingCandidate;
use App\Support\Baselines\Compare\CompareState;
use App\Support\Baselines\Compare\CompareSubjectIdentity;
use App\Support\Baselines\Compare\CompareSubjectProjection;
use App\Support\Baselines\Compare\CompareSubjectResult;
it('serializes compare subject results with structured finding and diagnostics payloads', function (): void {
$result = new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity(
domainKey: 'entra',
subjectClass: 'control',
subjectTypeKey: 'conditionalAccessPolicy',
externalSubjectId: 'cap-1',
subjectKey: 'cap-1',
),
projection: new CompareSubjectProjection(
platformSubjectClass: 'control',
domainKey: 'entra',
subjectTypeKey: 'conditionalAccessPolicy',
operatorLabel: 'Conditional Access Policy',
summaryKind: 'control_snapshot',
additionalLabels: ['family' => 'identity'],
),
baselineAvailability: 'available',
currentStateAvailability: 'available',
compareState: CompareState::Drift,
trustLevel: 'limited_confidence',
evidenceQuality: 'meta',
severityRecommendation: 'medium',
findingCandidate: new CompareFindingCandidate(
changeType: 'different_version',
severity: 'medium',
fingerprintBasis: [
'policy_type' => 'conditionalAccessPolicy',
'subject_key' => 'cap-1',
'change_type' => 'different_version',
],
evidencePayload: [
'summary' => ['kind' => 'control_snapshot'],
],
),
diagnostics: [
'strategy_key' => 'future_control',
],
);
$payload = $result->toArray();
expect($result->hasFindingCandidate())->toBeTrue()
->and($result->isGapState())->toBeFalse()
->and($payload['compare_state'])->toBe(CompareState::Drift->value)
->and($payload['baseline_availability'])->toBe('available')
->and($payload['current_state_availability'])->toBe('available')
->and($payload['projection']['platform_subject_class'])->toBe('control')
->and($payload['projection']['summary_kind'])->toBe('control_snapshot')
->and($payload['finding_candidate']['change_type'])->toBe('different_version')
->and($payload['finding_candidate']['severity'])->toBe('medium')
->and($payload['diagnostics']['strategy_key'])->toBe('future_control');
});
it('requires a finding candidate for drift results', function (): void {
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('intune', 'policy', 'deviceConfiguration', 'policy-1', 'policy-1'),
projection: new CompareSubjectProjection('policy', 'intune', 'deviceConfiguration', 'Policy 1'),
baselineAvailability: 'available',
currentStateAvailability: 'missing',
compareState: CompareState::Drift,
trustLevel: 'limited_confidence',
evidenceQuality: 'meta',
))->toThrow(InvalidArgumentException::class, 'require a finding candidate');
});
it('rejects non-drift results that still try to write findings', function (): void {
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
compareState: CompareState::Incomplete,
trustLevel: 'unusable',
evidenceQuality: 'missing',
findingCandidate: new CompareFindingCandidate(
changeType: 'different_version',
severity: 'high',
fingerprintBasis: ['subject_key' => 'cap-1'],
evidencePayload: [],
),
))->toThrow(InvalidArgumentException::class, 'Only drift compare subject results');
});
it('treats unsupported, incomplete, ambiguous, and failed states as gap states', function (): void {
$states = [
CompareState::Unsupported,
CompareState::Incomplete,
CompareState::Ambiguous,
CompareState::Failed,
];
foreach ($states as $state) {
$result = new CompareSubjectResult(
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
baselineAvailability: 'available',
currentStateAvailability: 'unknown',
compareState: $state,
trustLevel: 'unusable',
evidenceQuality: 'missing',
diagnostics: [
'reason_code' => 'strategy_failed',
'gap_record' => ['reason_code' => 'strategy_failed'],
],
);
expect($result->isGapState())->toBeTrue()
->and($result->gapReasonCode())->toBe('strategy_failed')
->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']);
}
});