TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ResolutionProofEvaluation.php
ahmido 83c679cf85 feat: add review publication proof currentness contract (#459)
Automated PR created by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #459
2026-06-19 19:10:35 +00:00

191 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Models\EnvironmentReview;
use Carbon\CarbonInterface;
final readonly class ResolutionProofEvaluation
{
/**
* @param array<string, mixed> $safeSummary
*/
public function __construct(
public ReviewPublicationResolutionStepKey $actionKey,
public string $subjectType,
public int $subjectId,
public ResolutionProofStatus $status,
public ResolutionProofCurrentness $currentness,
public ResolutionProofUsability $usability,
public ResolutionProofVisibility $visibility,
public string $reasonCode,
public ?ResolutionProofReference $reference = null,
public ?int $operationRunId = null,
public ?CarbonInterface $evaluatedAt = null,
public array $safeSummary = [],
) {}
public static function missing(
ReviewPublicationResolutionStepKey $actionKey,
EnvironmentReview $review,
string $reasonCode = 'proof.missing',
?CarbonInterface $evaluatedAt = null,
): self {
return new self(
actionKey: $actionKey,
subjectType: EnvironmentReview::class,
subjectId: (int) $review->getKey(),
status: ResolutionProofStatus::Missing,
currentness: ResolutionProofCurrentness::Unknown,
usability: ResolutionProofUsability::NotUsable,
visibility: ResolutionProofVisibility::OperatorVisible,
reasonCode: $reasonCode,
evaluatedAt: $evaluatedAt ?? now(),
safeSummary: [
'message' => 'Proof is missing for the current resolution step.',
],
);
}
public function canCompleteStep(): bool
{
if ($this->status !== ResolutionProofStatus::Available) {
return false;
}
if (! in_array($this->visibility, [
ResolutionProofVisibility::OperatorVisible,
], true)) {
return false;
}
if (! in_array($this->currentness, [
ResolutionProofCurrentness::Current,
ResolutionProofCurrentness::NotApplicable,
], true)) {
return false;
}
return in_array($this->usability, [
ResolutionProofUsability::Usable,
ResolutionProofUsability::UsableWithWarning,
], true);
}
/**
* @return array{
* proof_type:?string,
* proof_id:?int,
* proof_status:?string,
* operation_run_id:?int,
* proof_currentness:string,
* proof_usability:string,
* proof_visibility:string,
* proof_reason_code:string,
* proof_evaluated_at:?string,
* proof_timestamp:?string,
* proof_summary:array<string, mixed>
* }
*/
public function toStepPayload(): array
{
return [
'proof_type' => $this->reference?->proofType,
'proof_id' => $this->reference?->proofId,
'proof_status' => $this->reference?->sourceStatus ?? $this->status->value,
'operation_run_id' => $this->operationRunId,
'proof_currentness' => $this->currentness->value,
'proof_usability' => $this->usability->value,
'proof_visibility' => $this->visibility->value,
'proof_reason_code' => $this->reasonCode,
'proof_evaluated_at' => ($this->evaluatedAt ?? now())->toIso8601String(),
'proof_timestamp' => $this->reference?->proofTimestamp?->toIso8601String(),
'proof_summary' => self::sanitizeSummary($this->safeSummary),
];
}
/**
* @param array<string, mixed> $summary
* @return array<string, mixed>
*/
public static function sanitizeSummary(array $summary): array
{
$safe = [];
foreach ($summary as $key => $value) {
$key = (string) $key;
if (self::isUnsafeKey($key)) {
continue;
}
$sanitized = self::sanitizeValue($value);
if ($sanitized !== null) {
$safe[$key] = $sanitized;
}
}
return $safe;
}
private static function isUnsafeKey(string $key): bool
{
return preg_match('/(payload|graph|token|secret|password|credential|exception|raw|content|response|authorization|headers?|body|stack|trace|access[\\s_-]?token|client[\\s_-]?secret|full[\\s_-]?(report|evidence))/i', $key) === 1;
}
private static function isUnsafeString(string $value): bool
{
return preg_match('/(access[\\s_-]?token|refresh[\\s_-]?token|id[\\s_-]?token|client[\\s_-]?secret|secret[-_ ]?token|authorization\\s*:|bearer\\s+[a-z0-9._-]+|raw\\s*(graph|provider|http|response|payload)|graph\\s*(response|payload)|provider\\s*(response|payload)|http\\s*(response|body)|clientexception|serverexception|requestexception|exception\\b|stack\\s*trace|trace\\s*:)/i', $value) === 1;
}
private static function sanitizeValue(mixed $value): mixed
{
if ($value === null || is_bool($value) || is_int($value) || is_float($value)) {
return $value;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '' || self::isUnsafeString($value)) {
return null;
}
return mb_substr($value, 0, 240);
}
if (! is_array($value)) {
return null;
}
$nested = [];
$count = 0;
foreach ($value as $nestedKey => $nestedValue) {
if ($count >= 12) {
break;
}
$nestedKey = (string) $nestedKey;
if (self::isUnsafeKey($nestedKey)) {
continue;
}
$sanitized = self::sanitizeValue($nestedValue);
if ($sanitized === null) {
continue;
}
$nested[$nestedKey] = $sanitized;
$count++;
}
return $nested;
}
}