013-scripts-management (#19)
Adds scripts normalizer + safe script content display (opt-in, decoded, capped) Improves script diff UX: side-by-side + Before/After, Torchlight highlighting, fullscreen with scroll-sync Fixes Torchlight dark mode in diff lines Tests updated/added; ScriptPoliciesNormalizedDisplayTest.php passes Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #19
This commit is contained in:
parent
286d3c596b
commit
4cdd092637
@ -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);
|
||||
|
||||
|
||||
@ -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)')
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Intune\CompliancePolicyNormalizer;
|
||||
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
|
||||
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;
|
||||
@ -42,6 +43,7 @@ public function register(): void
|
||||
CompliancePolicyNormalizer::class,
|
||||
DeviceConfigurationPolicyNormalizer::class,
|
||||
GroupPolicyConfigurationNormalizer::class,
|
||||
ScriptsPolicyNormalizer::class,
|
||||
SettingsCatalogPolicyNormalizer::class,
|
||||
WindowsFeatureUpdateProfileNormalizer::class,
|
||||
WindowsQualityUpdateProfileNormalizer::class,
|
||||
|
||||
@ -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'] ?? [];
|
||||
|
||||
248
app/Services/Intune/ScriptsPolicyNormalizer.php
Normal file
248
app/Services/Intune/ScriptsPolicyNormalizer.php
Normal file
@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ScriptsPolicyNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
public function __construct(private readonly DefaultPolicyNormalizer $defaultNormalizer) {}
|
||||
|
||||
public function supports(string $policyType): bool
|
||||
{
|
||||
return in_array($policyType, [
|
||||
'deviceManagementScript',
|
||||
'deviceShellScript',
|
||||
'deviceHealthScript',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
|
||||
*/
|
||||
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<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>
|
||||
*/
|
||||
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
|
||||
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
193
composer.lock
generated
193
composer.lock
generated
@ -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",
|
||||
|
||||
@ -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),
|
||||
],
|
||||
];
|
||||
|
||||
@ -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('/<!--\s*Syntax highlighted by[^>]*-->/', '', $html);
|
||||
|
||||
if (! preg_match('/<code\b[^>]*>.*?<\\/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
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -103,11 +278,440 @@
|
||||
$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
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ (string) $name }}
|
||||
</div>
|
||||
|
||||
@if ($isScriptContent)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Script</span>
|
||||
<details class="mt-1" x-data="{ fullscreenOpen: false }">
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
|
||||
<div x-data="{ tab: 'diff' }" class="mt-2 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button size="xs" color="gray" type="button" x-on:click="fullscreenOpen = true">
|
||||
⤢ Fullscreen
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@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')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@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')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBefore) && $highlightedBefore !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedBefore !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfter) && $highlightedAfter !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-1 max-h-96 overflow-auto">{!! $highlightedAfter !!}</div>
|
||||
@else
|
||||
<pre class="mt-1 max-h-96 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="fullscreenOpen"
|
||||
x-cloak
|
||||
x-on:keydown.escape.window="fullscreenOpen = false"
|
||||
class="fixed inset-0 z-50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-950/50"></div>
|
||||
<div class="relative flex h-full w-full flex-col bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">Script diff</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="fullscreenOpen = false">
|
||||
Close
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div
|
||||
x-data="{
|
||||
tab: 'diff',
|
||||
syncing: false,
|
||||
syncHorizontal: true,
|
||||
sync(from, to) {
|
||||
if (this.syncing) return;
|
||||
this.syncing = true;
|
||||
|
||||
to.scrollTop = from.scrollTop;
|
||||
|
||||
const bothHorizontal = this.syncHorizontal
|
||||
&& from.scrollWidth > from.clientWidth
|
||||
&& to.scrollWidth > to.clientWidth;
|
||||
|
||||
if (bothHorizontal) {
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => { this.syncing = false; });
|
||||
},
|
||||
}"
|
||||
x-init="$nextTick(() => {
|
||||
const left = $refs.left;
|
||||
const right = $refs.right;
|
||||
|
||||
if (!left || !right) return;
|
||||
|
||||
left.addEventListener('scroll', () => sync(left, right), { passive: true });
|
||||
right.addEventListener('scroll', () => sync(right, left), { passive: true });
|
||||
})"
|
||||
class="h-full space-y-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'diff'" x-bind:class="tab === 'diff' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Diff
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'before'" x-bind:class="tab === 'before' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
Before
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" type="button" x-on:click="tab = 'after'" x-bind:class="tab === 'after' ? 'ring-1 ring-gray-300 dark:ring-white/20' : ''">
|
||||
After
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'diff'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Old</div>
|
||||
<pre x-ref="left" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@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')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($leftType === 'delete') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-danger-50 text-danger-700 dark:bg-danger-950/40 dark:text-danger-200">- '.$leftRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">New</div>
|
||||
<pre x-ref="right" class="mt-2 flex-1 overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">@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')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="tp-script-diff-line">'.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rightType === 'insert') {
|
||||
if ($useTorchlight) {
|
||||
@endphp
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
<style>
|
||||
.tp-script-diff-line code.torchlight {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@endonce
|
||||
@php
|
||||
}
|
||||
|
||||
echo '<span class="block tp-script-diff-line bg-success-50 text-success-700 dark:bg-success-950/40 dark:text-success-200">+ '.$rightRendered."</span>\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
@endphp</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'before'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Before</div>
|
||||
@php
|
||||
$highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedBeforeFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $fromText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'after'" x-cloak class="h-[calc(100%-3rem)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">After</div>
|
||||
@php
|
||||
$highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<div class="mt-2 h-full overflow-auto">{!! $highlightedAfterFullscreen !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 h-full overflow-auto font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre">{{ (string) $toText }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
|
||||
@if ($isExpandable($from))
|
||||
@ -134,6 +738,7 @@
|
||||
<div class="mt-1">{{ $toText }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
@ -149,7 +754,20 @@
|
||||
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
|
||||
View
|
||||
</summary>
|
||||
@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
|
||||
|
||||
<div class="mt-2 overflow-x-auto">{!! $highlighted !!}</div>
|
||||
@else
|
||||
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
|
||||
@endif
|
||||
</details>
|
||||
@else
|
||||
<div class="break-words">{{ $text }}</div>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
$warnings = $state['warnings'] ?? [];
|
||||
$settings = $state['settings'] ?? [];
|
||||
$settingsTable = $state['settings_table'] ?? null;
|
||||
$policyType = $state['policy_type'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -65,7 +66,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')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
@ -95,8 +100,15 @@
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
@else
|
||||
@php
|
||||
$value = $row['value'] ?? 'N/A';
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@endphp
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($row['value'] ?? 'N/A', 200) }}
|
||||
{{ Str::limit((string) $value, 200) }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
@ -105,7 +117,7 @@
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@elseif($block['type'] === 'keyValue')
|
||||
@elseif($blockType === 'keyValue')
|
||||
<x-filament::section
|
||||
:heading="$block['title'] ?? 'Settings'"
|
||||
collapsible
|
||||
@ -123,9 +135,95 @@
|
||||
{{ $entry['key'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white break-words">
|
||||
{{ Str::limit($entry['value'] ?? 'N/A', 200) }}
|
||||
@php
|
||||
$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)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@endphp
|
||||
@if($isScriptContent)
|
||||
@php
|
||||
$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 !== '')
|
||||
@once
|
||||
@include('filament.partials.torchlight-dark-overrides')
|
||||
@endonce
|
||||
|
||||
<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>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
<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>
|
||||
34
specs/013-scripts-management/checklists/requirements.md
Normal file
34
specs/013-scripts-management/checklists/requirements.md
Normal file
@ -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.
|
||||
42
specs/013-scripts-management/plan.md
Normal file
42
specs/013-scripts-management/plan.md
Normal file
@ -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.
|
||||
112
specs/013-scripts-management/spec.md
Normal file
112
specs/013-scripts-management/spec.md
Normal file
@ -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)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### 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)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### 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)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### 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.
|
||||
28
specs/013-scripts-management/tasks.md
Normal file
28
specs/013-scripts-management/tasks.md
Normal file
@ -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.
|
||||
128
tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php
Normal file
128
tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php
Normal file
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders policy version normalized settings for script policies', function (string $policyType, string $odataType) {
|
||||
$originalEnv = getenv('INTUNE_TENANT_ID');
|
||||
putenv('INTUNE_TENANT_ID=');
|
||||
|
||||
$this->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');
|
||||
});
|
||||
@ -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' => []]
|
||||
);
|
||||
|
||||
|
||||
168
tests/Unit/ScriptsPolicyNormalizerTest.php
Normal file
168
tests/Unit/ScriptsPolicyNormalizerTest.php
Normal file
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('normalizes deviceManagementScript into readable settings', function () {
|
||||
$normalizer = app(PolicyNormalizer::class);
|
||||
|
||||
$snapshot = [
|
||||
'@odata.type' => '#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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user