feat: scripts normalized settings and safe script content view

This commit is contained in:
Ahmed Darrazi 2026-01-01 15:16:48 +01:00
parent 058724c359
commit 17bfc2f17e
10 changed files with 597 additions and 29 deletions

View File

@ -623,6 +623,7 @@ private static function normalizedPolicyState(Policy $record): array
$normalized['context'] = 'policy'; $normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey(); $normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
$request->attributes->set($cacheKey, $normalized); $request->attributes->set($cacheKey, $normalized);

View File

@ -87,6 +87,7 @@ public static function infolist(Schema $schema): Schema
$normalized['context'] = 'version'; $normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey(); $normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
return $normalized; return $normalized;
}) })

View File

@ -39,22 +39,7 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
$entries[] = ['key' => 'Description', 'value' => $description]; $entries[] = ['key' => 'Description', 'value' => $description];
} }
// Script content and large blobs should not dominate normalized output. $entries = array_merge($entries, $this->contentEntries($snapshot));
// Keep only safe summary fields if present.
$contentKeys = [
'scriptContent',
'scriptContentBase64',
'detectionScriptContent',
'remediationScriptContent',
];
foreach ($contentKeys as $key) {
$value = Arr::get($snapshot, $key);
if (is_string($value) && $value !== '') {
$entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))];
}
}
$schedule = Arr::get($snapshot, 'runSchedule'); $schedule = Arr::get($snapshot, 'runSchedule');
if (is_array($schedule) && $schedule !== []) { if (is_array($schedule) && $schedule !== []) {
@ -84,6 +69,173 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor
]; ];
} }
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentEntries(array $snapshot): array
{
$showContent = (bool) config('tenantpilot.display.show_script_content', false);
$maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000);
if ($maxChars <= 0) {
$maxChars = 5000;
}
if (! $showContent) {
return $this->contentSummaryEntries($snapshot);
}
$entries = [];
$scriptContent = Arr::get($snapshot, 'scriptContent');
if (is_string($scriptContent) && $scriptContent !== '') {
$decoded = $this->decodeIfBase64Text($scriptContent);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $decoded;
}
}
if (! is_string($scriptContent) || $scriptContent === '') {
$scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64');
if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') {
$decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true);
if (is_string($decoded) && $decoded !== '') {
$scriptContent = $this->normalizeDecodedText($decoded);
}
}
}
if (is_string($scriptContent) && $scriptContent !== '') {
$entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)];
}
foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) {
$value = Arr::get($snapshot, $key);
if (! is_string($value) || $value === '') {
continue;
}
$decoded = $this->decodeIfBase64Text($value);
if (is_string($decoded) && $decoded !== '') {
$value = $decoded;
}
$entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)];
}
return $entries;
}
private function decodeIfBase64Text(string $candidate): ?string
{
$trimmed = $this->stripWhitespace($candidate);
if ($trimmed === '' || strlen($trimmed) < 16) {
return null;
}
if (strlen($trimmed) % 4 !== 0) {
return null;
}
if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) {
return null;
}
$decoded = base64_decode($trimmed, true);
if (! is_string($decoded) || $decoded === '') {
return null;
}
$decoded = $this->normalizeDecodedText($decoded);
if ($decoded === '') {
return null;
}
if (! $this->looksLikeText($decoded)) {
return null;
}
return $decoded;
}
private function stripWhitespace(string $value): string
{
return preg_replace('/\s+/', '', $value) ?? '';
}
private function normalizeDecodedText(string $decoded): string
{
if (str_starts_with($decoded, "\xFF\xFE")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE');
} elseif (str_starts_with($decoded, "\xFE\xFF")) {
$decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE');
} elseif (str_contains($decoded, "\x00")) {
$decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE');
}
if (str_starts_with($decoded, "\xEF\xBB\xBF")) {
$decoded = substr($decoded, 3);
}
return $decoded;
}
private function looksLikeText(string $decoded): bool
{
$length = strlen($decoded);
if ($length === 0) {
return false;
}
$nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0;
if ($nonPrintable > (int) max(1, $length * 0.05)) {
return false;
}
// Scripts should typically contain some whitespace or line breaks.
if ($length >= 24 && ! preg_match('/\s/', $decoded)) {
return false;
}
return true;
}
/**
* @return array<int, array{key: string, value: mixed}>
*/
private function contentSummaryEntries(array $snapshot): array
{
// Script content and large blobs should not dominate normalized output.
// Keep only safe summary fields if present.
$contentKeys = [
'scriptContent',
'scriptContentBase64',
'detectionScriptContent',
'remediationScriptContent',
];
$entries = [];
foreach ($contentKeys as $key) {
$value = Arr::get($snapshot, $key);
if (is_string($value) && $value !== '') {
$entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))];
}
}
return $entries;
}
private function limitContent(string $content, int $maxChars): string
{
if (mb_strlen($content) <= $maxChars) {
return $content;
}
return mb_substr($content, 0, $maxChars).'…';
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"filament/filament": "^4.0", "filament/filament": "^4.0",
"lara-zeus/torch-filament": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"pepperfm/filament-json": "^4" "pepperfm/filament-json": "^4"

193
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", "content-hash": "20819254265bddd0aa70006919cb735f",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@ -2082,6 +2082,87 @@
}, },
"time": "2025-11-13T14:57:49+00:00" "time": "2025-11-13T14:57:49+00:00"
}, },
{
"name": "lara-zeus/torch-filament",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/lara-zeus/torch-filament.git",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009",
"reference": "71dbe8df4a558a80308781ba20c5922943b33009",
"shasum": ""
},
"require": {
"filament/filament": "^4.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.16",
"torchlight/engine": "^0.1.0"
},
"require-dev": {
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.0",
"nunomaduro/phpinsights": "^2.8",
"orchestra/testbench": "^8.0",
"phpstan/extension-installer": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaraZeus\\TorchFilament\\TorchFilamentServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"LaraZeus\\TorchFilament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lara Zeus",
"email": "info@larazeus.com"
}
],
"description": "Infolist component to highlight code using Torchlight Engine",
"homepage": "https://larazeus.com/torch-filament",
"keywords": [
"code",
"design",
"engine",
"filamentphp",
"highlight",
"input",
"lara-zeus",
"laravel",
"torchlight",
"ui"
],
"support": {
"issues": "https://github.com/lara-zeus/torch-filament/issues",
"source": "https://github.com/lara-zeus/torch-filament"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/larazeus",
"type": "custom"
},
{
"url": "https://github.com/atmonshi",
"type": "github"
}
],
"time": "2025-06-11T19:32:10+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.42.0", "version": "v12.42.0",
@ -4265,6 +4346,60 @@
}, },
"time": "2025-02-26T00:08:40+00:00" "time": "2025-02-26T00:08:40+00:00"
}, },
{
"name": "phiki/phiki",
"version": "v1.1.6",
"source": {
"type": "git",
"url": "https://github.com/phikiphp/phiki.git",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b",
"reference": "3174d8cb309bdccc32b7a33500379de76148256b",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"php": "^8.2"
},
"require-dev": {
"illuminate/support": "^11.30",
"laravel/pint": "^1.18.1",
"pestphp/pest": "^3.5.1",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.0",
"symfony/var-dumper": "^7.1.6"
},
"bin": [
"bin/phiki"
],
"type": "library",
"autoload": {
"psr-4": {
"Phiki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Chandler",
"email": "support@ryangjchandler.co.uk",
"homepage": "https://ryangjchandler.co.uk",
"role": "Developer"
}
],
"description": "Syntax highlighting using TextMate grammars in PHP.",
"support": {
"issues": "https://github.com/phikiphp/phiki/issues",
"source": "https://github.com/phikiphp/phiki/tree/v1.1.6"
},
"time": "2025-06-06T20:18:29+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.4", "version": "1.9.4",
@ -8110,6 +8245,62 @@
}, },
"time": "2024-12-21T16:25:41+00:00" "time": "2024-12-21T16:25:41+00:00"
}, },
{
"name": "torchlight/engine",
"version": "v0.1.0",
"source": {
"type": "git",
"url": "https://github.com/torchlight-api/engine.git",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d",
"shasum": ""
},
"require": {
"league/commonmark": "^2.5.3",
"phiki/phiki": "^1.1.4",
"php": "^8.2"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"laravel/pint": "^1.13",
"pestphp/pest": "^2"
},
"type": "library",
"autoload": {
"psr-4": {
"Torchlight\\Engine\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aaron Francis",
"email": "aaron@hammerstone.dev"
},
{
"name": "John Koster",
"email": "john@stillat.com"
}
],
"description": "The PHP-based Torchlight code annotation and rendering engine.",
"keywords": [
"Code highlighting",
"syntax highlighting"
],
"support": {
"issues": "https://github.com/torchlight-api/engine/issues",
"source": "https://github.com/torchlight-api/engine/tree/v0.1.0"
},
"time": "2025-04-02T01:47:48+00:00"
},
{ {
"name": "ueberdosis/tiptap-php", "name": "ueberdosis/tiptap-php",
"version": "2.0.0", "version": "2.0.0",

View File

@ -218,4 +218,9 @@
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
], ],
'display' => [
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
],
]; ];

View File

@ -7,6 +7,7 @@
$warnings = $state['warnings'] ?? []; $warnings = $state['warnings'] ?? [];
$settings = $state['settings'] ?? []; $settings = $state['settings'] ?? [];
$settingsTable = $state['settings_table'] ?? null; $settingsTable = $state['settings_table'] ?? null;
$policyType = $state['policy_type'] ?? null;
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
@ -137,13 +138,102 @@
@php @php
$value = $entry['value'] ?? 'N/A'; $value = $entry['value'] ?? 'N/A';
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
if (is_array($value) || is_object($value)) { if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }
@endphp @endphp
<span class="text-sm text-gray-900 dark:text-white break-words"> @if($isScriptContent)
{{ Str::limit((string) $value, 200) }} @php
</span> $code = (string) $value;
$firstLine = strtok($code, "\n") ?: '';
$grammar = 'powershell';
if ($policyType === 'deviceShellScript') {
$shebang = trim($firstLine);
if (str_starts_with($shebang, '#!')) {
if (str_contains($shebang, 'zsh')) {
$grammar = 'zsh';
} elseif (str_contains($shebang, 'bash')) {
$grammar = 'bash';
} else {
$grammar = 'sh';
}
} else {
$grammar = 'sh';
}
} elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') {
$grammar = 'powershell';
}
$highlightedHtml = null;
if (class_exists(\Torchlight\Engine\Engine::class)) {
try {
$highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml(
code: $code,
grammar: $grammar,
theme: [
'light' => 'github-light',
'dark' => 'github-dark',
],
withGutter: false,
withWrapper: true,
);
} catch (\Throwable $e) {
$highlightedHtml = null;
}
}
@endphp
<div x-data="{ open: false }" class="space-y-2">
<div class="flex items-center gap-2">
<x-filament::button
size="xs"
color="gray"
type="button"
x-on:click="open = !open"
>
<span x-show="!open" x-cloak>Show</span>
<span x-show="open" x-cloak>Hide</span>
</x-filament::button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format(Str::length($code)) }} chars
</span>
</div>
<div x-show="open" x-cloak>
@if (is_string($highlightedHtml) && $highlightedHtml !== '')
<style>
html.dark code.torchlight {
background-color: var(--phiki-dark-background-color) !important;
}
html.dark .phiki,
html.dark .phiki span {
color: var(--phiki-dark-color) !important;
font-style: var(--phiki-dark-font-style) !important;
font-weight: var(--phiki-dark-font-weight) !important;
text-decoration: var(--phiki-dark-text-decoration) !important;
}
</style>
<div class="overflow-x-auto">{!! $highlightedHtml !!}</div>
@else
<pre class="text-xs font-mono text-gray-900 dark:text-white whitespace-pre-wrap break-words">{{ $code }}</pre>
@endif
</div>
</div>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">
{{ Str::limit((string) $value, 200) }}
</span>
@endif
</dd> </dd>
</div> </div>
@endforeach @endforeach

View File

@ -4,17 +4,22 @@ # Tasks: Scripts Management (013)
**Input**: [spec.md](./spec.md), [plan.md](./plan.md) **Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Phase 1: Contracts Review ## Phase 1: Contracts Review
- [ ] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). - [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key).
## Phase 2: UI Normalization ## Phase 2: UI Normalization
- [ ] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. - [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types.
- [ ] T003 Register the normalizer in `AppServiceProvider`. - [x] T003 Register the normalizer in `AppServiceProvider`.
## Phase 3: Tests + Verification ## Phase 3: Tests + Verification
- [ ] T004 Add tests for normalized output (shape + stability) for each script policy type. - [x] T004 Add tests for normalized output (shape + stability) for each script policy type.
- [ ] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. - [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type.
- [ ] T006 Run targeted tests. - [x] T006 Run targeted tests.
- [ ] T007 Run Pint (`./vendor/bin/pint --dirty`). - [x] T007 Run Pint (`./vendor/bin/pint --dirty`).
## Phase 4: Script Content Display (Safe)
- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings.
- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default).
- [x] T010 Hide script content behind a Show/Hide button (collapsed by default).
## Open TODOs (Follow-up) ## Open TODOs (Follow-up)
- None yet. - None yet.

View File

@ -26,7 +26,16 @@
'external_id' => 'policy-1', 'external_id' => 'policy-1',
]); ]);
PolicyVersion::factory()->create([ config([
'tenantpilot.display.show_script_content' => true,
]);
$scriptContent = str_repeat('X', 20);
if ($policyType === 'deviceShellScript') {
$scriptContent = "#!/bin/zsh\n".str_repeat('X', 20);
}
$version = PolicyVersion::factory()->create([
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'policy_type' => $policyType, 'policy_type' => $policyType,
@ -34,13 +43,16 @@
'@odata.type' => $odataType, '@odata.type' => $odataType,
'displayName' => 'Script policy', 'displayName' => 'Script policy',
'description' => 'desc', 'description' => 'desc',
'scriptContent' => str_repeat('X', 20), 'scriptContent' => $scriptContent,
], ],
]); ]);
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index'))
->assertSuccessful(); ->assertSuccessful();
$this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings')
->assertSuccessful();
$originalEnv !== false $originalEnv !== false
? putenv("INTUNE_TENANT_ID={$originalEnv}") ? putenv("INTUNE_TENANT_ID={$originalEnv}")
: putenv('INTUNE_TENANT_ID'); : putenv('INTUNE_TENANT_ID');

View File

@ -56,3 +56,113 @@
expect($result['settings'])->toBeArray()->not->toBeEmpty(); expect($result['settings'])->toBeArray()->not->toBeEmpty();
expect($result['settings'][0]['type'])->toBe('keyValue'); expect($result['settings'][0]['type'])->toBe('keyValue');
}); });
it('summarizes script content by default', function () {
config([
'tenantpilot.display.show_script_content' => false,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => 'ABC',
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('[content: 3 chars]');
});
it('shows script content when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 100,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => "line1\nline2",
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe("line1\nline2");
});
it('decodes scriptContentBase64 when enabled and scriptContent is missing', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 50,
]);
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceShellScript',
'displayName' => 'My macOS shell script',
'scriptContentBase64' => base64_encode('echo hello'),
];
$result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('echo hello');
});
it('decodes base64-looking scriptContent when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$normalizer = app(PolicyNormalizer::class);
$plain = "# hello\nWrite-Host \"hi\"";
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementScript',
'displayName' => 'My PS script',
'scriptContent' => base64_encode($plain),
];
$result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe($plain);
});
it('decodes base64-looking detection/remediation script content when enabled', function () {
config([
'tenantpilot.display.show_script_content' => true,
'tenantpilot.display.max_script_content_chars' => 5000,
]);
$normalizer = app(PolicyNormalizer::class);
$detection = "# detection\nWrite-Host \"detect\"";
$remediation = "# remediation\nWrite-Host \"fix\"";
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceHealthScript',
'displayName' => 'My remediation',
'detectionScriptContent' => base64_encode($detection),
'remediationScriptContent' => base64_encode($remediation),
];
$result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows');
$entries = collect($result['settings'][0]['entries']);
expect($entries->firstWhere('key', 'detectionScriptContent')['value'])->toBe($detection);
expect($entries->firstWhere('key', 'remediationScriptContent')['value'])->toBe($remediation);
});