## Summary
This PR completes spec 422 exchange teams comparable renderable pack with comparable diffing, renderable summary builders, and comprehensive test updates.
## Commit
- 4c1e14c6 feat: complete spec 422 exchange teams comparable renderable pack
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #489
833 lines
25 KiB
PHP
833 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
final class ExchangeTeamsComparablePayloadNormalizer
|
|
{
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SUPPORTED_TYPES = [
|
|
'transportRule',
|
|
'acceptedDomain',
|
|
'appPermissionPolicy',
|
|
'meetingPolicy',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const VOLATILE_ROOT_FIELDS = [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'createdAt',
|
|
'createdDateTime',
|
|
'created_at',
|
|
'graphContext',
|
|
'lastModifiedDateTime',
|
|
'modifiedDateTime',
|
|
'sourceMetadata',
|
|
'source_metadata',
|
|
'tcmContext',
|
|
'updatedAt',
|
|
'updatedDateTime',
|
|
'updated_at',
|
|
'version',
|
|
'whenChanged',
|
|
];
|
|
|
|
/**
|
|
* @var array<string, list<string>>
|
|
*/
|
|
private const SUPPORTED_ROOT_FIELDS = [
|
|
'transportRule' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'Actions',
|
|
'ApplyHtmlDisclaimerFallbackAction',
|
|
'ApplyHtmlDisclaimerLocation',
|
|
'Conditions',
|
|
'DeleteMessage',
|
|
'DisplayName',
|
|
'Enabled',
|
|
'ExceptIfFrom',
|
|
'ExceptIfRecipientDomainIs',
|
|
'Exceptions',
|
|
'From',
|
|
'Identity',
|
|
'Mode',
|
|
'Name',
|
|
'Priority',
|
|
'RecipientDomainIs',
|
|
'RedirectMessageTo',
|
|
'SenderDomainIs',
|
|
'SentTo',
|
|
'State',
|
|
'SubjectContainsWords',
|
|
'actions',
|
|
'conditions',
|
|
'createdDateTime',
|
|
'displayName',
|
|
'enabled',
|
|
'exceptions',
|
|
'id',
|
|
'mode',
|
|
'modifiedDateTime',
|
|
'name',
|
|
'priority',
|
|
'ruleMode',
|
|
'state',
|
|
],
|
|
'acceptedDomain' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'Default',
|
|
'DisplayName',
|
|
'DomainName',
|
|
'DomainType',
|
|
'Identity',
|
|
'IsDefault',
|
|
'Name',
|
|
'State',
|
|
'Status',
|
|
'createdDateTime',
|
|
'default',
|
|
'displayName',
|
|
'domainName',
|
|
'domainType',
|
|
'id',
|
|
'isDefault',
|
|
'modifiedDateTime',
|
|
'name',
|
|
'state',
|
|
'status',
|
|
'type',
|
|
],
|
|
'appPermissionPolicy' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'AllowAllApps',
|
|
'AllowAppList',
|
|
'AssignedGroups',
|
|
'AssignedUsers',
|
|
'BlockAppList',
|
|
'Description',
|
|
'DisplayName',
|
|
'GlobalCatalogAppsType',
|
|
'Identity',
|
|
'Name',
|
|
'PrivateCatalogAppsType',
|
|
'allowedAppIds',
|
|
'allowedApps',
|
|
'allowAppList',
|
|
'assignments',
|
|
'assignedGroups',
|
|
'assignedUsers',
|
|
'blockedAppIds',
|
|
'blockedApps',
|
|
'blockAppList',
|
|
'createdDateTime',
|
|
'displayName',
|
|
'id',
|
|
'mode',
|
|
'modifiedDateTime',
|
|
'name',
|
|
'policyMode',
|
|
'targets',
|
|
],
|
|
'meetingPolicy' => [
|
|
'@odata.context',
|
|
'@odata.etag',
|
|
'AllowAnonymousUsersToJoinMeeting',
|
|
'AllowCloudRecording',
|
|
'AllowExternalParticipantGiveRequestControl',
|
|
'AllowMeetingReactions',
|
|
'AllowParticipantGiveRequestControl',
|
|
'AllowPowerPointSharing',
|
|
'AllowPSTNUsersToBypassLobby',
|
|
'AllowSharedNotes',
|
|
'AllowTranscription',
|
|
'AllowWhiteboard',
|
|
'AutoAdmittedUsers',
|
|
'ContentSharing',
|
|
'Description',
|
|
'DisplayName',
|
|
'ExternalAccess',
|
|
'Identity',
|
|
'LobbyBypassSettings',
|
|
'MeetingRecordingExpirationDays',
|
|
'Name',
|
|
'RecordingTranscription',
|
|
'ScreenSharingMode',
|
|
'State',
|
|
'allowAnonymousUsersToJoinMeeting',
|
|
'allowCloudRecording',
|
|
'allowExternalParticipantGiveRequestControl',
|
|
'allowMeetingReactions',
|
|
'allowParticipantGiveRequestControl',
|
|
'allowPowerPointSharing',
|
|
'allowPSTNUsersToBypassLobby',
|
|
'allowSharedNotes',
|
|
'allowTranscription',
|
|
'allowWhiteboard',
|
|
'autoAdmittedUsers',
|
|
'contentSharing',
|
|
'createdDateTime',
|
|
'displayName',
|
|
'externalAccess',
|
|
'id',
|
|
'lobbyBypassSettings',
|
|
'meetingRecordingExpirationDays',
|
|
'modifiedDateTime',
|
|
'name',
|
|
'recordingTranscription',
|
|
'screenSharingMode',
|
|
'state',
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SENSITIVE_CONTENT_KEYS = [
|
|
'auditmetadata.rawpayload',
|
|
'body',
|
|
'chatcontent',
|
|
'chatmessage',
|
|
'filecontent',
|
|
'mailbody',
|
|
'mailcontent',
|
|
'messagebody',
|
|
'mimecontent',
|
|
'operationruncontext',
|
|
'providerresponse',
|
|
'rawpayload',
|
|
'recordingcontent',
|
|
'recordingtranscript',
|
|
'recordingurl',
|
|
'transcriptcontent',
|
|
'transcripttext',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SENSITIVE_CONTENT_KEY_PARTS = [
|
|
'applyhtmldisclaimertext',
|
|
'attachmentcontainswords',
|
|
'attachmentextensionmatcheswords',
|
|
'attachmentmatchespatterns',
|
|
'attachmentnamematchespatterns',
|
|
'attachmentpropertycontainswords',
|
|
'bodycontainswords',
|
|
'bodymatchespatterns',
|
|
'disclaimertext',
|
|
'headercontainsmessageheader',
|
|
'headercontainswords',
|
|
'headermatchespatterns',
|
|
'messageheadercontainswords',
|
|
'messageheadermatchespatterns',
|
|
'prependsubject',
|
|
'setheadername',
|
|
'setheadervalue',
|
|
'subjectcontainswords',
|
|
'subjectmatchespatterns',
|
|
'subjectorbodycontainswords',
|
|
'subjectorbodymatchespatterns',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly CoveragePayloadRedactor $redactor,
|
|
) {}
|
|
|
|
public function supports(string $canonicalType): bool
|
|
{
|
|
return in_array($canonicalType, self::SUPPORTED_TYPES, true);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function normalize(string $canonicalType, array $payload): array
|
|
{
|
|
if (! $this->supports($canonicalType)) {
|
|
return [
|
|
'canonical_type' => $canonicalType,
|
|
'supported' => false,
|
|
'diagnostics' => [
|
|
'unsupported_fields' => [],
|
|
'redacted_fields' => [],
|
|
'volatile_fields' => [],
|
|
],
|
|
];
|
|
}
|
|
|
|
$redacted = $this->redactedPayload($payload);
|
|
|
|
return match ($canonicalType) {
|
|
'transportRule' => $this->normalizeTransportRule($payload, $redacted),
|
|
'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted),
|
|
'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted),
|
|
'meetingPolicy' => $this->normalizeMeetingPolicy($payload, $redacted),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function volatileRootFields(): array
|
|
{
|
|
return self::VOLATILE_ROOT_FIELDS;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeTransportRule(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'transportRule',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
|
|
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['enabled', 'Enabled', 'state', 'State'])),
|
|
'priority_order' => $this->stringValue($this->firstScalar($payload, ['priority', 'Priority'])),
|
|
'mode' => $this->firstString($payload, ['mode', 'Mode', 'ruleMode', 'RuleMode']),
|
|
'conditions' => $this->settingGroup($payload, ['conditions', 'Conditions'], [
|
|
'From',
|
|
'RecipientDomainIs',
|
|
'SenderDomainIs',
|
|
'SentTo',
|
|
'SubjectContainsWords',
|
|
]),
|
|
'actions' => $this->settingGroup($payload, ['actions', 'Actions'], [
|
|
'ApplyHtmlDisclaimerFallbackAction',
|
|
'ApplyHtmlDisclaimerLocation',
|
|
'DeleteMessage',
|
|
'RedirectMessageTo',
|
|
]),
|
|
'exceptions' => $this->settingGroup($payload, ['exceptions', 'Exceptions'], [
|
|
'ExceptIfFrom',
|
|
'ExceptIfRecipientDomainIs',
|
|
]),
|
|
'source' => $this->sourceSummary($payload),
|
|
'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeAcceptedDomain(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'acceptedDomain',
|
|
'supported' => true,
|
|
'domain_name' => $this->firstString($payload, ['domainName', 'DomainName', 'name', 'Name', 'displayName', 'DisplayName', 'Identity']),
|
|
'domain_type' => $this->firstString($payload, ['domainType', 'DomainType', 'type']),
|
|
'is_default' => $this->booleanString($this->firstScalar($payload, ['isDefault', 'IsDefault', 'default', 'Default'])),
|
|
'state' => $this->firstString($payload, ['state', 'State', 'status', 'Status']),
|
|
'source' => $this->sourceSummary($payload),
|
|
'diagnostics' => $this->diagnostics('acceptedDomain', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeAppPermissionPolicy(array $rawPayload, array $payload): array
|
|
{
|
|
$globalCatalogMode = $this->firstString($payload, ['GlobalCatalogAppsType', 'globalCatalogAppsType']);
|
|
$privateCatalogMode = $this->firstString($payload, ['PrivateCatalogAppsType', 'privateCatalogAppsType']);
|
|
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'appPermissionPolicy',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
|
|
'policy_mode' => $this->policyMode([
|
|
$this->firstScalar($payload, ['mode', 'policyMode']),
|
|
$globalCatalogMode,
|
|
$privateCatalogMode,
|
|
$this->firstScalar($payload, ['AllowAllApps', 'allowAllApps']),
|
|
]),
|
|
'allowed_apps' => $this->appList($this->firstExisting($payload, ['allowedApps', 'AllowAppList', 'allowAppList', 'allowedAppIds'])),
|
|
'blocked_apps' => $this->appList($this->firstExisting($payload, ['blockedApps', 'BlockAppList', 'blockAppList', 'blockedAppIds'])),
|
|
'targets' => $this->settingGroup($payload, ['assignments', 'targets'], ['AssignedGroups', 'assignedGroups', 'AssignedUsers', 'assignedUsers']),
|
|
'source' => $this->sourceSummary($payload),
|
|
'diagnostics' => $this->diagnostics('appPermissionPolicy', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeMeetingPolicy(array $rawPayload, array $payload): array
|
|
{
|
|
return $this->sortAssociative([
|
|
'canonical_type' => 'meetingPolicy',
|
|
'supported' => true,
|
|
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
|
|
'state' => $this->firstString($payload, ['state', 'State']),
|
|
'external_access' => $this->settingGroup($payload, ['externalAccess', 'ExternalAccess'], [
|
|
'allowAnonymousUsersToJoinMeeting',
|
|
'AllowAnonymousUsersToJoinMeeting',
|
|
'allowExternalParticipantGiveRequestControl',
|
|
'AllowExternalParticipantGiveRequestControl',
|
|
]),
|
|
'recording_transcription' => $this->settingGroup($payload, ['recordingTranscription', 'RecordingTranscription'], [
|
|
'allowCloudRecording',
|
|
'AllowCloudRecording',
|
|
'allowTranscription',
|
|
'AllowTranscription',
|
|
'meetingRecordingExpirationDays',
|
|
'MeetingRecordingExpirationDays',
|
|
]),
|
|
'lobby_admission' => $this->settingGroup($payload, ['lobbyBypassSettings', 'LobbyBypassSettings'], [
|
|
'autoAdmittedUsers',
|
|
'AutoAdmittedUsers',
|
|
'allowPSTNUsersToBypassLobby',
|
|
'AllowPSTNUsersToBypassLobby',
|
|
]),
|
|
'content_sharing' => $this->settingGroup($payload, ['contentSharing', 'ContentSharing'], [
|
|
'screenSharingMode',
|
|
'ScreenSharingMode',
|
|
'allowPowerPointSharing',
|
|
'AllowPowerPointSharing',
|
|
'allowWhiteboard',
|
|
'AllowWhiteboard',
|
|
'allowSharedNotes',
|
|
'AllowSharedNotes',
|
|
'allowMeetingReactions',
|
|
'AllowMeetingReactions',
|
|
'allowParticipantGiveRequestControl',
|
|
'AllowParticipantGiveRequestControl',
|
|
]),
|
|
'source' => $this->sourceSummary($payload),
|
|
'diagnostics' => $this->diagnostics('meetingPolicy', $rawPayload, $payload),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactedPayload(array $payload): array
|
|
{
|
|
$redacted = $this->redactor->redact($payload);
|
|
$redacted = is_array($redacted) ? $redacted : [];
|
|
|
|
return $this->redactUnsafeContent($redacted);
|
|
}
|
|
|
|
private function redactUnsafeContent(mixed $value, string $path = ''): mixed
|
|
{
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value);
|
|
}
|
|
|
|
$redacted = [];
|
|
|
|
foreach ($value as $key => $nestedValue) {
|
|
$key = (string) $key;
|
|
$nestedPath = $path === '' ? $key : $path.'.'.$key;
|
|
|
|
$redacted[$key] = $this->isUnsafeContentKey($nestedPath)
|
|
? '[redacted]'
|
|
: $this->redactUnsafeContent($nestedValue, $nestedPath);
|
|
}
|
|
|
|
return $redacted;
|
|
}
|
|
|
|
private function isUnsafeContentKey(string $path): bool
|
|
{
|
|
$normalized = strtolower(str_replace(['_', '-', ' '], '', $path));
|
|
$segments = explode('.', $normalized);
|
|
$last = end($segments);
|
|
|
|
foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) {
|
|
if ($normalized === $sensitiveKey || $last === $sensitiveKey) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) {
|
|
if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param list<string> $groupKeys
|
|
* @param list<string> $rootKeys
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array
|
|
{
|
|
$settings = [];
|
|
|
|
foreach ($groupKeys as $groupKey) {
|
|
$group = $payload[$groupKey] ?? null;
|
|
|
|
if (is_array($group)) {
|
|
foreach ($this->normalizeSettingMap($group) as $key => $value) {
|
|
$settings[$key] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($rootKeys as $rootKey) {
|
|
if (! array_key_exists($rootKey, $payload)) {
|
|
continue;
|
|
}
|
|
|
|
$settings[$this->settingKey($rootKey)] = $this->normalizeSettingValue($payload[$rootKey]);
|
|
}
|
|
|
|
ksort($settings, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $settings
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeSettingMap(array $settings): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($settings as $key => $value) {
|
|
$key = $this->settingKey((string) $key);
|
|
|
|
if ($this->isUnsafeContentKey($key)) {
|
|
$normalized[$key] = '[redacted]';
|
|
|
|
continue;
|
|
}
|
|
|
|
$normalized[$key] = $this->normalizeSettingValue($value);
|
|
}
|
|
|
|
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function normalizeSettingValue(mixed $value): mixed
|
|
{
|
|
if ($value === '[redacted]') {
|
|
return $value;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_scalar($value)) {
|
|
$value = trim((string) $value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
$items = array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value);
|
|
$items = array_values(array_filter($items, static fn (mixed $item): bool => $item !== null && $item !== ''));
|
|
|
|
usort($items, static fn (mixed $left, mixed $right): int => strcmp(
|
|
json_encode($left, JSON_THROW_ON_ERROR),
|
|
json_encode($right, JSON_THROW_ON_ERROR),
|
|
));
|
|
|
|
return $items;
|
|
}
|
|
|
|
return $this->normalizeSettingMap($value);
|
|
}
|
|
|
|
private function settingKey(string $key): string
|
|
{
|
|
return str($key)
|
|
->replaceMatches('/[^A-Za-z0-9]+/', '_')
|
|
->snake()
|
|
->trim('_')
|
|
->toString();
|
|
}
|
|
|
|
private function firstExisting(array $payload, array $fields): mixed
|
|
{
|
|
foreach ($fields as $field) {
|
|
if (array_key_exists($field, $payload)) {
|
|
return $payload[$field];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function firstScalar(array $payload, array $fields): mixed
|
|
{
|
|
foreach ($fields as $field) {
|
|
$value = $payload[$field] ?? null;
|
|
|
|
if (is_scalar($value)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function firstString(array $payload, array $fields): ?string
|
|
{
|
|
return $this->stringValue($this->firstScalar($payload, $fields));
|
|
}
|
|
|
|
private function stringValue(mixed $value): ?string
|
|
{
|
|
if (! is_scalar($value)) {
|
|
return null;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value ? 'true' : 'false';
|
|
}
|
|
|
|
$value = trim((string) $value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
private function enabledState(mixed $value): ?string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'enabled' : 'disabled';
|
|
}
|
|
|
|
$value = $this->stringValue($value);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower($value)) {
|
|
'1', 'enabled', 'true', 'yes' => 'enabled',
|
|
'0', 'disabled', 'false', 'no' => 'disabled',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
private function booleanString(mixed $value): ?string
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value ? 'yes' : 'no';
|
|
}
|
|
|
|
$value = $this->stringValue($value);
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
return match (strtolower($value)) {
|
|
'1', 'true', 'yes' => 'yes',
|
|
'0', 'false', 'no' => 'no',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<mixed> $candidates
|
|
*/
|
|
private function policyMode(array $candidates): ?string
|
|
{
|
|
$parts = [];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (is_bool($candidate)) {
|
|
$parts[] = $candidate ? 'allow all apps' : 'selected apps only';
|
|
|
|
continue;
|
|
}
|
|
|
|
$value = $this->stringValue($candidate);
|
|
|
|
if ($value !== null) {
|
|
$parts[] = $value;
|
|
}
|
|
}
|
|
|
|
$parts = array_values(array_unique($parts));
|
|
|
|
return $parts === [] ? null : implode('; ', $parts);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function appList(mixed $value): array
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return [];
|
|
}
|
|
|
|
$items = is_array($value) && array_is_list($value) ? $value : [$value];
|
|
$apps = [];
|
|
|
|
foreach ($items as $item) {
|
|
if (is_array($item)) {
|
|
$apps[] = array_filter([
|
|
'display_name' => $this->firstString($item, ['displayName', 'DisplayName', 'name', 'Name']),
|
|
'app_id' => $this->firstString($item, ['appId', 'AppId', 'id', 'Id', 'externalId']),
|
|
], static fn (mixed $nested): bool => $nested !== null && $nested !== '');
|
|
|
|
continue;
|
|
}
|
|
|
|
$value = $this->stringValue($item);
|
|
|
|
if ($value !== null) {
|
|
$apps[] = ['display_name' => $value];
|
|
}
|
|
}
|
|
|
|
$apps = array_values(array_filter($apps, static fn (array $item): bool => $item !== []));
|
|
|
|
usort($apps, static fn (array $left, array $right): int => strcmp(
|
|
json_encode($left, JSON_THROW_ON_ERROR),
|
|
json_encode($right, JSON_THROW_ON_ERROR),
|
|
));
|
|
|
|
return $apps;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, ?string>
|
|
*/
|
|
private function sourceSummary(array $payload): array
|
|
{
|
|
return array_filter([
|
|
'version' => $this->firstString($payload, ['sourceVersion', 'source_version']),
|
|
'schema_hash' => $this->firstString($payload, ['sourceSchemaHash', 'source_schema_hash']),
|
|
], static fn (?string $value): bool => $value !== null && $value !== '');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawPayload
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, list<string>>
|
|
*/
|
|
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
|
|
{
|
|
return [
|
|
'unsupported_fields' => $this->unsupportedRootFields($canonicalType, $payload),
|
|
'redacted_fields' => $this->redactedPaths($payload),
|
|
'volatile_fields' => $this->presentVolatileFields($rawPayload),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function unsupportedRootFields(string $canonicalType, array $payload): array
|
|
{
|
|
$supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? [];
|
|
$fields = array_values(array_filter(
|
|
array_map('strval', array_keys($payload)),
|
|
static fn (string $key): bool => ! in_array($key, $supported, true),
|
|
));
|
|
|
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return list<string>
|
|
*/
|
|
private function presentVolatileFields(array $payload): array
|
|
{
|
|
$fields = array_values(array_filter(
|
|
self::VOLATILE_ROOT_FIELDS,
|
|
static fn (string $field): bool => array_key_exists($field, $payload),
|
|
));
|
|
|
|
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function redactedPaths(mixed $value, string $prefix = ''): array
|
|
{
|
|
if ($value === '[redacted]') {
|
|
return [$prefix];
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
$paths = [];
|
|
|
|
foreach ($value as $key => $nestedValue) {
|
|
$path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key;
|
|
|
|
foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) {
|
|
$paths[] = $nestedPath;
|
|
}
|
|
}
|
|
|
|
$paths = array_values(array_unique(array_filter($paths)));
|
|
sort($paths, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sortAssociative(array $value): array
|
|
{
|
|
foreach ($value as $key => $nestedValue) {
|
|
if (is_array($nestedValue)) {
|
|
$value[$key] = array_is_list($nestedValue)
|
|
? $nestedValue
|
|
: $this->sortAssociative($nestedValue);
|
|
}
|
|
}
|
|
|
|
ksort($value, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
return $value;
|
|
}
|
|
}
|