diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bea7788..bbfac49 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -623,6 +623,7 @@ private static function normalizedPolicyState(Policy $record): array $normalized['context'] = 'policy'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; $request->attributes->set($cacheKey, $normalized); diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4bab649..2b01621 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -87,6 +87,7 @@ public static function infolist(Schema $schema): Schema $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; return $normalized; }) @@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema : []; $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); - return $diff->compare($from, $to); + $result = $diff->compare($from, $to); + $result['policy_type'] = $record->policy_type; + + return $result; }), Infolists\Components\ViewEntry::make('diff_json') ->label('Raw diff (advanced)') diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index eba5c33..10e2a8e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; @@ -44,6 +45,7 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 6bd2139..29aa0b5 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -39,7 +39,7 @@ public function fetch( $primaryException = null; $assignments = []; - $primarySucceeded = false; + $lastSuccessfulAssignments = null; // Try primary endpoint(s) $listPathTemplates = []; @@ -65,7 +65,12 @@ public function fetch( $context, $throwOnFailure ); - $primarySucceeded = true; + + if ($assignments === null) { + continue; + } + + $lastSuccessfulAssignments = $assignments; if (! empty($assignments)) { Log::debug('Fetched assignments via primary endpoint', [ @@ -77,20 +82,25 @@ public function fetch( return $assignments; } + + if ($policyType !== 'appProtectionPolicy') { + // Empty is a valid outcome (policy not assigned). Do not attempt fallback. + return []; + } } catch (GraphException $e) { $primaryException = $primaryException ?? $e; } } - if ($primarySucceeded && $policyType === 'appProtectionPolicy') { + if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') { Log::debug('Assignments fetched via primary endpoint(s)', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, - 'count' => count($assignments), + 'count' => count($lastSuccessfulAssignments), ]); - return $assignments; + return $lastSuccessfulAssignments; } // Try fallback with $expand @@ -215,15 +225,15 @@ private function fetchPrimary( array $options, array $context, bool $throwOnFailure - ): array { + ): ?array { if (! is_string($listPathTemplate) || $listPathTemplate === '') { - return []; + return null; } $path = $this->resolvePath($listPathTemplate, $policyId); if ($path === null) { - return []; + return null; } $response = $this->graphClient->request('GET', $path, $options); @@ -239,7 +249,7 @@ private function fetchPrimary( ); } - return []; + return null; } return $response->data['value'] ?? []; diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php new file mode 100644 index 0000000..68bf7e5 --- /dev/null +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -0,0 +1,248 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $entries = []; + + $entries[] = ['key' => 'Type', 'value' => $policyType]; + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $entries[] = ['key' => 'Description', 'value' => $description]; + } + + $entries = array_merge($entries, $this->contentEntries($snapshot)); + + $schedule = Arr::get($snapshot, 'runSchedule'); + if (is_array($schedule) && $schedule !== []) { + $entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + } + + $frequency = Arr::get($snapshot, 'runFrequency'); + if (is_string($frequency) && $frequency !== '') { + $entries[] = ['key' => 'Run frequency', 'value' => $frequency]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Script settings', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/composer.json b/composer.json index ccdbb83..b681b4b 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "lara-zeus/torch-filament": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "pepperfm/filament-json": "^4" diff --git a/composer.lock b/composer.lock index 33f7c64..4a56c47 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", + "content-hash": "20819254265bddd0aa70006919cb735f", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2082,6 +2082,87 @@ }, "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", "version": "v12.42.0", @@ -4265,6 +4346,60 @@ }, "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", "version": "1.9.4", @@ -8110,6 +8245,62 @@ }, "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", "version": "2.0.0", diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 7ee583b..7ec3820 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -218,4 +218,9 @@ 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), '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), + ], ]; diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index c57ebd1..a7623b8 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -1,6 +1,7 @@ @php $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $summary = $diff['summary'] ?? []; + $policyType = $diff['policy_type'] ?? null; $groupByBlock = static function (array $items): array { $groups = []; @@ -50,6 +51,180 @@ return is_string($value) && strlen($value) > 160; }; + + $isScriptKey = static function (mixed $name): bool { + return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true); + }; + + $canHighlightScripts = static function (?string $policyType): bool { + return (bool) config('tenantpilot.display.show_script_content', false) + && in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript'], true); + }; + + $selectGrammar = static function (?string $policyType, string $code): string { + if ($policyType === 'deviceShellScript') { + $firstLine = strtok($code, "\n") ?: ''; + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + return 'zsh'; + } + + if (str_contains($shebang, 'bash')) { + return 'bash'; + } + + return 'sh'; + } + + return 'sh'; + } + + return 'powershell'; + }; + + $highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + try { + return (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + return null; + } + }; + + $highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + if ($code === '') { + return ''; + } + + try { + $html = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: false, + ); + + $html = (string) preg_replace('//', '', $html); + + if (! preg_match('/]*>.*?<\\/code>/s', $html, $matches)) { + return null; + } + + return trim((string) ($matches[0] ?? '')); + } catch (\Throwable $e) { + return null; + } + }; + + $splitLines = static function (string $text): array { + $text = str_replace(["\r\n", "\r"], "\n", $text); + + return $text === '' ? [] : explode("\n", $text); + }; + + $myersLineDiff = static function (array $a, array $b): array { + $n = count($a); + $m = count($b); + $max = $n + $m; + + $v = [1 => 0]; + $trace = []; + + for ($d = 0; $d <= $max; $d++) { + $trace[$d] = $v; + + for ($k = -$d; $k <= $d; $k += 2) { + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $x = $kPlus; + } else { + $x = $kMinus + 1; + } + + $y = $x - $k; + + while ($x < $n && $y < $m && $a[$x] === $b[$y]) { + $x++; + $y++; + } + + $v[$k] = $x; + + if ($x >= $n && $y >= $m) { + break 2; + } + } + } + + $ops = []; + $x = $n; + $y = $m; + + for ($d = count($trace) - 1; $d >= 0; $d--) { + $v = $trace[$d]; + $k = $x - $y; + + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $prevK = $k + 1; + } else { + $prevK = $k - 1; + } + + $prevX = $v[$prevK] ?? 0; + $prevY = $prevX - $prevK; + + while ($x > $prevX && $y > $prevY) { + $ops[] = ['type' => 'equal', 'line' => $a[$x - 1]]; + $x--; + $y--; + } + + if ($d === 0) { + break; + } + + if ($x === $prevX) { + $ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? '']; + $y--; + } else { + $ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? '']; + $x--; + } + } + + return array_reverse($ops); + }; + + $scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array { + return $myersLineDiff($splitLines($fromText), $splitLines($toText)); + }; @endphp
@@ -103,37 +278,467 @@ $to = $value['to']; $fromText = $stringify($from); $toText = $stringify($to); + + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : []; + $useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class); + + $rows = []; + if ($isScriptContent) { + $count = count($ops); + + for ($i = 0; $i < $count; $i++) { + $op = $ops[$i]; + $next = $ops[$i + 1] ?? null; + $type = $op['type'] ?? null; + $line = (string) ($op['line'] ?? ''); + + if ($type === 'equal') { + $rows[] = [ + 'left' => ['type' => 'equal', 'line' => $line], + 'right' => ['type' => 'equal', 'line' => $line], + ]; + continue; + } + + if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')], + ]; + $i++; + continue; + } + + if ($type === 'delete') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'blank', 'line' => ''], + ]; + continue; + } + + if ($type === 'insert') { + $rows[] = [ + 'left' => ['type' => 'blank', 'line' => ''], + 'right' => ['type' => 'insert', 'line' => $line], + ]; + continue; + } + } + } @endphp
{{ (string) $name }}
-
- From - @if ($isExpandable($from)) -
+ + @if ($isScriptContent) +
+ Script +
View -
{{ $fromText }}
+ +
+
+ + Diff + + + Before + + + After + + + + ⤢ Fullscreen + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBefore) && $highlightedBefore !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBefore !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfter) && $highlightedAfter !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfter !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+ +
+
+
+
+
Script diff
+
+ + Close + +
+
+ +
+
+
+ + Diff + + + Before + + + After + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBeforeFullscreen !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfterFullscreen !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+
+
+
- @else -
{{ $fromText }}
- @endif -
-
- To - @if ($isExpandable($to)) -
- - View - -
{{ $toText }}
-
- @else -
{{ $toText }}
- @endif -
+
+ @else +
+ From + @if ($isExpandable($from)) +
+ + View + +
{{ $fromText }}
+
+ @else +
{{ $fromText }}
+ @endif +
+
+ To + @if ($isExpandable($to)) +
+ + View + +
{{ $toText }}
+
+ @else +
{{ $toText }}
+ @endif +
+ @endif
@else @php @@ -149,7 +754,20 @@ View -
{{ $text }}
+ @php + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null; + @endphp + + @if (is_string($highlighted) && $highlighted !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlighted !!}
+ @else +
{{ $text }}
+ @endif @else
{{ $text }}
diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 015fd5d..f644e9f 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -8,6 +8,8 @@ $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; + $stringifyValue = function (mixed $value): string { if (is_null($value)) { return 'N/A'; @@ -115,7 +117,11 @@ {{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}} @foreach($settings as $block) - @if($block['type'] === 'table') + @php + $blockType = is_array($block) ? ($block['type'] ?? null) : null; + @endphp + + @if($blockType === 'table') - @elseif($block['type'] === 'keyValue') + @elseif($blockType === 'keyValue')
- @if($shouldRenderBadges($entry['value'] ?? null)) + @php + $rawValue = $entry['value'] ?? null; + + $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) + && (bool) config('tenantpilot.display.show_script_content', false); + @endphp + + @if($isScriptContent) + @php + $code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue); + $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 + +
+
+ + Show + Hide + + + + {{ number_format(Str::length($code)) }} chars + +
+ +
+ @if (is_string($highlightedHtml) && $highlightedHtml !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedHtml !!}
+ @else +
{{ $code }}
+ @endif +
+
+ @elseif($shouldRenderBadges($rawValue))
- @foreach(($entry['value'] ?? []) as $item) + @foreach(($rawValue ?? []) as $item) {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} @@ -191,7 +278,7 @@
@else - {{ Str::limit($stringifyValue($entry['value'] ?? null), 200) }} + {{ Str::limit($stringifyValue($rawValue), 200) }} @endif
diff --git a/resources/views/filament/partials/torchlight-dark-overrides.blade.php b/resources/views/filament/partials/torchlight-dark-overrides.blade.php new file mode 100644 index 0000000..e93868a --- /dev/null +++ b/resources/views/filament/partials/torchlight-dark-overrides.blade.php @@ -0,0 +1,13 @@ + diff --git a/specs/013-scripts-management/checklists/requirements.md b/specs/013-scripts-management/checklists/requirements.md new file mode 100644 index 0000000..89849c9 --- /dev/null +++ b/specs/013-scripts-management/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Scripts Management + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Assumptions: Supported script policy types are already discoverable in the product, and restore/assignments follow existing system patterns. diff --git a/specs/013-scripts-management/plan.md b/specs/013-scripts-management/plan.md new file mode 100644 index 0000000..57970df --- /dev/null +++ b/specs/013-scripts-management/plan.md @@ -0,0 +1,42 @@ +# Plan: Scripts Management (013) + +**Branch**: `013-scripts-management` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments. + +## Scope + +### In scope +- Script policy types: + - `deviceManagementScript` + - `deviceShellScript` + - `deviceHealthScript` +- Readable “Normalized settings” output for the above types. +- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior). +- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata). + +### Out of scope +- Adding new UI flows or pages. +- Introducing new external services or background infrastructure. +- Changing how authentication/authorization works. + +## Approach +1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys). +2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure. +3. Register the normalizer in the application normalizer tag. +4. Add tests: + - Normalized output shape/stability for each type. + - Filament “Normalized settings” tab renders without errors for a version of each type. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed). +- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable. + +## Success Criteria +- Normalized settings views are readable and stable for all three script policy types. +- Restore execution remains safe and assignment behavior is unchanged/regression-free. +- Tests cover the new normalizer behavior and basic UI render. diff --git a/specs/013-scripts-management/spec.md b/specs/013-scripts-management/spec.md new file mode 100644 index 0000000..b8446df --- /dev/null +++ b/specs/013-scripts-management/spec.md @@ -0,0 +1,112 @@ +# Feature Specification: Scripts Management + +**Feature Branch**: `013-scripts-management` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Add end-to-end support for management scripts (Windows PowerShell scripts, macOS shell scripts, and proactive remediations) including readable normalized settings, backup snapshots, and safe restore with assignments." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore a script safely (Priority: P1) + +As an admin, I want to restore a script policy from a saved snapshot so I can recover from accidental or unwanted changes. + +**Why this priority**: Restoring known-good configuration is the core safety value of the product. + +**Independent Test**: Can be fully tested by restoring one script policy into a tenant where the script is missing or changed, and verifying the script and its assignments match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved script snapshot and a target tenant where the script does not exist, **When** I run restore for that item, **Then** the system creates a new script policy from the snapshot and reports success. +2. **Given** a saved script snapshot and a target tenant where the script exists with differences, **When** I run restore for that item, **Then** the system updates the existing script policy to match the snapshot and reports success. +3. **Given** a saved script snapshot with assignments, **When** I run restore, **Then** the system applies the assignments using the snapshot data and reports assignment outcomes. + +--- + +### User Story 2 - Readable script configuration (Priority: P2) + +As an admin, I want to view a readable, normalized representation of a script policy so I can understand what it does and compare versions reliably. + +**Why this priority**: If admins cannot quickly understand changes, version history and restore become risky and slow. + +**Independent Test**: Can be tested by opening a script policy version page and confirming that normalized settings display key fields consistently across versions. + +**Acceptance Scenarios**: + +1. **Given** a script policy version, **When** I open the policy version details, **Then** I see a normalized settings view that is stable (same input yields same output ordering/shape). +2. **Given** two versions of the same script policy with changes, **When** I view their normalized settings, **Then** the differences are visible without reading raw JSON. + +--- + +### User Story 3 - Reliable backup capture (Priority: P3) + +As an admin, I want backups/version snapshots of script policies to be captured reliably so I can restore later with confidence. + +**Why this priority**: Restore is only as good as the snapshot quality. + +**Independent Test**: Can be tested by capturing a snapshot of each script policy type and validating it contains the expected configuration fields for that policy. + +**Acceptance Scenarios**: + +1. **Given** an existing script policy, **When** I capture a snapshot/backup, **Then** the saved snapshot contains the complete configuration needed to restore the script policy. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Restoring a snapshot whose policy type does not match the target item (type mismatch) must fail clearly without making changes. +- Restoring when the snapshot contains fields that are not accepted by the target environment must result in a clear failure reason and no partial silent data loss. +- Assignments referencing groups or foundations that cannot be mapped must be reported as manual-required for those assignments. +- Script policies with very large or complex configuration should still render a readable normalized settings view (with safe truncation if needed). + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing script policies for the supported script policy types. +- **FR-002**: System MUST allow capturing a snapshot of a script policy that is sufficient to restore the policy later. +- **FR-003**: System MUST allow restoring a script policy from a snapshot in a safe manner (create when missing; update when present). +- **FR-004**: System MUST support restoring assignments for script policies using the assignments saved with the snapshot. +- **FR-005**: System MUST present a readable normalized settings view for script policies and script policy versions. +- **FR-006**: System MUST prevent execution of restore if the snapshot policy type does not match the restore item type. +- **FR-007**: System MUST record an audit trail for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Script Policy**: A configuration object representing a management script (platform-specific variants), identified by a stable external identifier and a display name. +- **Script Policy Snapshot**: An immutable capture of a script policy’s configuration at a point in time, used for diffing and restore. +- **Script Assignment**: A target association that applies a script policy to a defined scope (e.g., groups/filters), stored with the snapshot and restored with mapping when needed. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single script policy in under 1 minute. +- **SC-002**: In a test tenant, restoring a script policy results in the target script policy and assignments matching the snapshot for 100% of supported script policy types. +- **SC-003**: Normalized settings for a script policy are readable and stable: repeated views of the same snapshot produce identical normalized output. +- **SC-004**: Restore failures provide a clear reason (actionable message) in 100% of failure cases. diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md new file mode 100644 index 0000000..92d99ee --- /dev/null +++ b/specs/013-scripts-management/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Scripts Management (013) + +**Branch**: `013-scripts-management` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). + +## Phase 2: UI Normalization +- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [x] T003 Register the normalizer in `AppServiceProvider`. + +## Phase 3: Tests + Verification +- [x] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [x] T006 Run targeted tests. +- [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). +- [x] T011 Highlight script content in Normalized Diff view (From/To). +- [x] T012 Enable Torchlight highlighting in Diff + Before/After views. +- [x] T013 Add “Fullscreen” overlay for script diffs (scroll sync). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php new file mode 100644 index 0000000..9db3154 --- /dev/null +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -0,0 +1,128 @@ +actingAs(User::factory()->create()); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'platform' => 'all', + 'display_name' => 'Script policy', + 'external_id' => 'policy-1', + ]); + + 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, + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'snapshot' => [ + '@odata.type' => $odataType, + 'displayName' => 'Script policy', + 'description' => 'desc', + 'scriptContent' => $scriptContent, + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) + ->assertSuccessful(); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings') + ->assertSuccessful(); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +})->with([ + ['deviceManagementScript', '#microsoft.graph.deviceManagementScript'], + ['deviceShellScript', '#microsoft.graph.deviceShellScript'], + ['deviceHealthScript', '#microsoft.graph.deviceHealthScript'], +]); + +it('renders diff tab with highlighted script content for script policies', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $this->actingAs(User::factory()->create()); + + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = \App\Models\Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + ]); + + $scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40); + $scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40); + + $v1 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptOne), + ], + ]); + + $v2 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptTwo), + ], + ]); + + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); + + $this->get($url.'?tab=diff') + ->assertSuccessful() + ->assertSeeText('Fullscreen') + ->assertSeeText("- Write-Host 'one'") + ->assertSeeText("+ Write-Host 'two'") + ->assertSee('bg-danger-50', false) + ->assertSee('bg-success-50', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index ce174ee..b634752 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -75,15 +75,11 @@ expect($result)->toBe($assignments); }); -test('fallback on empty response', function () { +test('does not use fallback when primary succeeds with empty assignments', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - $assignments = [ - ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], - ]; - // Primary returns empty $primaryResponse = new GraphResponse( success: true, data: ['value' => []] @@ -97,7 +93,34 @@ ]) ->andReturn($primaryResponse); - // Fallback returns assignments + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); + + expect($result)->toBe([]); +}); + +test('uses fallback when primary endpoint fails', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ]; + + $primaryFailure = new GraphResponse( + success: false, + data: [], + status: 400, + errors: [['message' => 'Bad Request']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($primaryFailure); + $fallbackResponse = new GraphResponse( success: true, data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]] @@ -152,18 +175,6 @@ ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) ->andReturn($primaryResponse); - // Fallback returns empty - $fallbackResponse = new GraphResponse( - success: true, - data: ['value' => []] - ); - - $this->graphClient - ->shouldReceive('request') - ->once() - ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) - ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); @@ -174,9 +185,8 @@ $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - // Primary returns empty $primaryResponse = new GraphResponse( - success: true, + success: false, data: ['value' => []] ); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php new file mode 100644 index 0000000..61a63a7 --- /dev/null +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -0,0 +1,168 @@ + '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'description' => 'Does a thing', + 'scriptContent' => str_repeat('A', 10), + 'runFrequency' => 'weekly', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); + expect(collect($result['settings'][0]['entries'])->pluck('key')->all())->toContain('Display name'); +}); + +it('normalizes deviceShellScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContent' => str_repeat('B', 5), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); +}); + +it('normalizes deviceHealthScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => str_repeat('C', 3), + 'remediationScriptContent' => str_repeat('D', 4), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + 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); +});