Added `BaselineReadinessGate`, resolution propagation, and disclosure semantics logic per Spec 385. Integrates baseline unreadiness into Customer Review Workspace and Review Packs to prevent report generation when identity bindings are unresolved. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #456
253 lines
9.9 KiB
PHP
253 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ProviderResourceBinding;
|
|
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
|
|
use App\Support\Baselines\CompareSemantics\CompareResultReason;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\Resources\ProviderResourceResolutionMode;
|
|
|
|
it('keeps baseline drift posture missing when no drift findings or compare proof exist', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
ProviderResourceBinding::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
|
|
->and($payload['summary_payload']['drift_count'])->toBe(0)
|
|
->and($payload['summary_payload'])->not->toHaveKey('provider_resource_bindings')
|
|
->and($payload['summary_payload']['latest_compare_run_id'])->toBeNull();
|
|
});
|
|
|
|
it('does not mark old successful compare context as complete without structured readiness semantics', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: ['reason_code' => 'baseline.compare.no_drift_detected'],
|
|
outcome: OperationRunOutcome::Succeeded->value,
|
|
);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
|
|
->and($payload['summary_payload']['drift_count'])->toBe(0)
|
|
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_unproven')
|
|
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('marks no baseline drift complete when structured compare semantics verify no drift', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
$run = seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
|
|
);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Complete->value)
|
|
->and($payload['measured_at']?->equalTo($run->completed_at))->toBeTrue()
|
|
->and($payload['summary_payload']['drift_count'])->toBe(0)
|
|
->and($payload['summary_payload']['latest_compare_run_id'])->toBe((int) $run->getKey())
|
|
->and($payload['summary_payload']['latest_compare_outcome'])->toBe(OperationRunOutcome::Succeeded->value)
|
|
->and($payload['summary_payload']['baseline_readiness']['customer_safe_claim'])->toBe('customer_ready');
|
|
});
|
|
|
|
it('honors active provider resource decisions from stored bindings', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
|
|
);
|
|
|
|
ProviderResourceBinding::factory()->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'resolution_mode' => ProviderResourceResolutionMode::AcceptedLimitation->value,
|
|
]);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Partial->value)
|
|
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_limited')
|
|
->and($payload['summary_payload']['baseline_readiness']['limitation_codes'])->toBe(['baseline_accepted_limitations'])
|
|
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->toBe([]);
|
|
});
|
|
|
|
it('blocks when stored binding decisions were revoked after the latest compare', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: spec385EvidenceCompareContext([CompareResultReason::VerifiedNoDrift]),
|
|
completedAt: now()->subMinutes(5),
|
|
);
|
|
|
|
ProviderResourceBinding::factory()
|
|
->revoked()
|
|
->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'ended_at' => now(),
|
|
]);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Partial->value)
|
|
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_compare_blocked')
|
|
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->toContain('Baseline subject decisions changed after the latest compare; refresh evidence before publication.');
|
|
});
|
|
|
|
it('surfaces structured baseline evidence gaps as readiness blockers', function (): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: spec385EvidenceCompareContext([CompareResultReason::MissingLocalEvidence]),
|
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe(EvidenceCompletenessState::Missing->value)
|
|
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe('baseline_local_evidence_missing')
|
|
->and($payload['summary_payload']['baseline_readiness']['next_action'])->toBe('open_evidence_basis')
|
|
->and($payload['summary_payload']['baseline_readiness']['publication_blockers'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('maps structured baseline readiness source edge cases through the evidence item', function (
|
|
CompareResultReason $reason,
|
|
int $driftCount,
|
|
string $outcome,
|
|
?int $completedDaysAgo,
|
|
string $expectedState,
|
|
string $expectedReadinessState,
|
|
array $expectedLimitationCodes,
|
|
): void {
|
|
[, $tenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
|
|
if ($driftCount > 0) {
|
|
\App\Models\Finding::factory()
|
|
->count($driftCount)
|
|
->create([
|
|
'managed_environment_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'finding_type' => \App\Models\Finding::FINDING_TYPE_DRIFT,
|
|
]);
|
|
}
|
|
|
|
seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: spec385EvidenceCompareContext([$reason]),
|
|
outcome: $outcome,
|
|
completedAt: $completedDaysAgo === null ? now() : now()->subDays($completedDaysAgo),
|
|
);
|
|
|
|
$payload = app(BaselineDriftPostureSource::class)->collect($tenant);
|
|
|
|
expect($payload['state'])->toBe($expectedState)
|
|
->and($payload['summary_payload']['baseline_readiness']['readiness_state'])->toBe($expectedReadinessState)
|
|
->and($payload['summary_payload']['baseline_readiness']['limitation_codes'])->toBe($expectedLimitationCodes);
|
|
})->with([
|
|
'trusted drift' => [
|
|
CompareResultReason::VerifiedDriftDetected,
|
|
1,
|
|
OperationRunOutcome::Succeeded->value,
|
|
null,
|
|
EvidenceCompletenessState::Complete->value,
|
|
'trusted_drift_detected',
|
|
[],
|
|
],
|
|
'accepted limitation' => [
|
|
CompareResultReason::AcceptedLimitation,
|
|
0,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
null,
|
|
EvidenceCompletenessState::Partial->value,
|
|
'baseline_compare_limited',
|
|
['baseline_accepted_limitations'],
|
|
],
|
|
'excluded subject' => [
|
|
CompareResultReason::ExcludedNonGoverned,
|
|
0,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
null,
|
|
EvidenceCompletenessState::Partial->value,
|
|
'baseline_compare_limited',
|
|
['baseline_exclusions_present'],
|
|
],
|
|
'failed compare' => [
|
|
CompareResultReason::CompareFailed,
|
|
0,
|
|
OperationRunOutcome::Failed->value,
|
|
null,
|
|
EvidenceCompletenessState::Missing->value,
|
|
'baseline_compare_failed',
|
|
[],
|
|
],
|
|
'stale compare' => [
|
|
CompareResultReason::VerifiedNoDrift,
|
|
0,
|
|
OperationRunOutcome::Succeeded->value,
|
|
45,
|
|
EvidenceCompletenessState::Stale->value,
|
|
'baseline_compare_stale',
|
|
[],
|
|
],
|
|
]);
|
|
|
|
/**
|
|
* @param list<CompareResultReason> $reasons
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec385EvidenceCompareContext(array $reasons): array
|
|
{
|
|
$byReason = [];
|
|
$byReadinessImpact = [];
|
|
|
|
foreach ($reasons as $reason) {
|
|
$byReason[$reason->value] = ($byReason[$reason->value] ?? 0) + 1;
|
|
|
|
$impact = $reason->readinessImpact()->value;
|
|
$byReadinessImpact[$impact] = ($byReadinessImpact[$impact] ?? 0) + 1;
|
|
}
|
|
|
|
return [
|
|
'result_semantics' => [
|
|
'version' => 'compare_semantics.v1',
|
|
'run_outcome' => 'completed',
|
|
'operation_outcome' => OperationRunOutcome::Succeeded->value,
|
|
'counts' => [
|
|
'by_reason' => $byReason,
|
|
'by_readiness_impact' => $byReadinessImpact,
|
|
],
|
|
],
|
|
];
|
|
}
|