Summary Consolidates the “Tenant Operate Hub” work (Spec 085) and the follow-up adjustments from the 086 session merge into a single branch ready to merge into dev. Primary focus: stabilize Ops/Operate Hub UX flows, tighten/align authorization semantics, and make the full Sail test suite green. Key Changes Ops UX / Verification Readonly members can view verification operation runs (reports) while starting verification remains restricted. Normalized failure reason-code handling and aligned UX expectations with the provider reason-code taxonomy. Onboarding wizard UX “Start verification” CTA is hidden while a verification run is active; “Refresh” is shown during in-progress runs. Treats provider_permission_denied as a blocking reason (while keeping legacy compatibility). Test + fixture hardening Standardized use of default provider connection fixtures in tests where sync/restore flows require it. Fixed multiple Filament URL/tenant-context test cases to avoid 404s and reduce tenancy routing brittleness. Policy sync / restore safety Enrollment configuration type collision classification tests now exercise the real sync path (with required provider connection present). Restore edge-case safety tests updated to reflect current provider-connection requirements. Testing vendor/bin/sail artisan test --compact (green) vendor/bin/sail bin pint --dirty (green) Notes Includes merged 086 session work already (no separate PR needed). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box> Reviewed-on: #103
589 lines
21 KiB
PHP
589 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Models\BackupItem;
|
|
use App\Models\BackupSchedule;
|
|
use App\Models\BackupScheduleRun;
|
|
use App\Models\BackupSet;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\ProviderCredential;
|
|
use App\Models\Tenant;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\Inventory\InventoryMetaSanitizer;
|
|
use App\Services\Inventory\InventoryMissingService;
|
|
use App\Services\Inventory\InventorySyncService;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function fakeGraphClient(array $policiesByType = [], array $failedTypes = [], ?Throwable $throwable = null): GraphClientInterface
|
|
{
|
|
return new class($policiesByType, $failedTypes, $throwable) implements GraphClientInterface
|
|
{
|
|
public function __construct(
|
|
private readonly array $policiesByType,
|
|
private readonly array $failedTypes,
|
|
private readonly ?Throwable $throwable,
|
|
) {}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
if ($this->throwable instanceof Throwable) {
|
|
throw $this->throwable;
|
|
}
|
|
|
|
if (in_array($policyType, $this->failedTypes, true)) {
|
|
return new GraphResponse(false, [], 403, ['error' => ['code' => 'Forbidden', 'message' => 'forbidden']], [], []);
|
|
}
|
|
|
|
return new GraphResponse(true, $this->policiesByType[$policyType] ?? []);
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, []);
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, []);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, []);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, []);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
return new GraphResponse(true, []);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Executes an inventory sync against a canonical OperationRun.
|
|
*
|
|
* @param array<string, mixed> $selection
|
|
* @return array{opRun: \App\Models\OperationRun, result: array<string, mixed>, selection: array<string, mixed>, selection_hash: string}
|
|
*/
|
|
function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
|
{
|
|
$service = app(InventorySyncService::class);
|
|
$opService = app(OperationRunService::class);
|
|
|
|
$defaultConnection = ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('provider', 'microsoft')
|
|
->where('is_default', true)
|
|
->first();
|
|
|
|
if (! $defaultConnection instanceof ProviderConnection) {
|
|
$defaultConnection = ProviderConnection::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => $tenant->tenant_id,
|
|
'is_default' => true,
|
|
'status' => 'ok',
|
|
]);
|
|
|
|
ProviderCredential::factory()->create([
|
|
'provider_connection_id' => (int) $defaultConnection->getKey(),
|
|
'type' => 'client_secret',
|
|
'payload' => [
|
|
'client_id' => 'test-client-id',
|
|
'client_secret' => 'test-client-secret',
|
|
],
|
|
]);
|
|
}
|
|
|
|
$computed = $service->normalizeAndHashSelection($selection);
|
|
$context = array_merge($computed['selection'], [
|
|
'selection_hash' => $computed['selection_hash'],
|
|
]);
|
|
|
|
$opRun = $opService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: 'inventory.sync',
|
|
identityInputs: [
|
|
'selection_hash' => $computed['selection_hash'],
|
|
],
|
|
context: $context,
|
|
initiator: null,
|
|
);
|
|
|
|
$result = $service->executeSelection($opRun, $tenant, $context);
|
|
$status = (string) ($result['status'] ?? 'failed');
|
|
|
|
$outcome = match ($status) {
|
|
'success' => OperationRunOutcome::Succeeded->value,
|
|
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
|
default => OperationRunOutcome::Failed->value,
|
|
};
|
|
|
|
$opService->updateRun(
|
|
$opRun,
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: $outcome,
|
|
summaryCounts: [
|
|
'total' => count($computed['selection']['policy_types'] ?? []),
|
|
],
|
|
);
|
|
|
|
return [
|
|
'opRun' => $opRun->refresh(),
|
|
'result' => $result,
|
|
'selection' => $computed['selection'],
|
|
'selection_hash' => $computed['selection_hash'],
|
|
];
|
|
}
|
|
|
|
test('inventory sync upserts and updates last_seen fields without duplicates', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [
|
|
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
|
|
],
|
|
]));
|
|
|
|
$selection = [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
$runA = executeInventorySyncNow($tenant, $selection);
|
|
expect($runA['result']['status'] ?? null)->toBe('success');
|
|
|
|
$item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first();
|
|
expect($item)->not->toBeNull();
|
|
expect($item->external_id)->toBe('cfg-1');
|
|
expect($item->last_seen_operation_run_id)->toBe($runA['opRun']->id);
|
|
|
|
$runB = executeInventorySyncNow($tenant, $selection);
|
|
|
|
$items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get();
|
|
expect($items)->toHaveCount(1);
|
|
|
|
$items->first()->refresh();
|
|
expect($items->first()->last_seen_operation_run_id)->toBe($runB['opRun']->id);
|
|
});
|
|
|
|
test('inventory sync includes foundation types when include_foundations is true', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
'roleScopeTag' => [
|
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
|
],
|
|
'assignmentFilter' => [
|
|
['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'],
|
|
],
|
|
'notificationMessageTemplate' => [
|
|
['id' => 'tmpl-1', 'displayName' => 'Template 1', '@odata.type' => '#microsoft.graph.notificationMessageTemplate'],
|
|
],
|
|
]));
|
|
|
|
$run = executeInventorySyncNow($tenant, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => [],
|
|
'include_foundations' => true,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($run['result']['status'] ?? null)->toBe('success');
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'roleScopeTag')
|
|
->where('external_id', 'tag-1')
|
|
->where('category', 'Foundations')
|
|
->exists())->toBeTrue();
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'assignmentFilter')
|
|
->where('external_id', 'filter-1')
|
|
->where('category', 'Foundations')
|
|
->exists())->toBeTrue();
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'notificationMessageTemplate')
|
|
->where('external_id', 'tmpl-1')
|
|
->where('category', 'Foundations')
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
test('inventory sync does not sync foundation types when include_foundations is false', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'roleScopeTag' => [
|
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
|
],
|
|
]));
|
|
|
|
$run = executeInventorySyncNow($tenant, [
|
|
'policy_types' => ['roleScopeTag'],
|
|
'categories' => [],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($run['result']['status'] ?? null)->toBe('success');
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'roleScopeTag')
|
|
->exists())->toBeFalse();
|
|
});
|
|
|
|
test('foundation inventory items store sanitized meta_jsonb after sync (no payload dump)', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
'roleScopeTag' => [
|
|
[
|
|
'id' => 'tag-1',
|
|
'displayName' => 'Scope Tag 1',
|
|
'@odata.type' => '#microsoft.graph.roleScopeTag',
|
|
'veryLargePayload' => str_repeat('x', 10_000),
|
|
'client_secret' => 'should-not-end-up-anywhere',
|
|
],
|
|
],
|
|
]));
|
|
|
|
$run = executeInventorySyncNow($tenant, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => [],
|
|
'include_foundations' => true,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($run['result']['status'] ?? null)->toBe('success');
|
|
|
|
$foundationItem = \App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'roleScopeTag')
|
|
->where('external_id', 'tag-1')
|
|
->first();
|
|
|
|
expect($foundationItem)->not->toBeNull();
|
|
|
|
$stored = is_array($foundationItem->meta_jsonb) ? $foundationItem->meta_jsonb : [];
|
|
$sanitizer = app(InventoryMetaSanitizer::class);
|
|
|
|
expect($stored)->toBe($sanitizer->sanitize($stored));
|
|
expect(json_encode($stored))->not->toContain('Bearer ');
|
|
expect(json_encode($stored))->not->toContain('should-not-end-up-anywhere');
|
|
expect(json_encode($stored))->not->toContain(str_repeat('x', 200));
|
|
});
|
|
|
|
test('inventory sync run counts include foundations when enabled and exclude them when disabled (deterministic)', function () {
|
|
$tenantA = Tenant::factory()->create();
|
|
$tenantB = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [
|
|
[
|
|
'id' => 'pol-1',
|
|
'displayName' => 'Policy 1',
|
|
'@odata.type' => '#microsoft.graph.deviceConfiguration',
|
|
],
|
|
],
|
|
'roleScopeTag' => [
|
|
['id' => 'tag-1', 'displayName' => 'Scope Tag 1', '@odata.type' => '#microsoft.graph.roleScopeTag'],
|
|
],
|
|
'assignmentFilter' => [
|
|
['id' => 'filter-1', 'displayName' => 'Filter 1', '@odata.type' => '#microsoft.graph.assignmentFilter'],
|
|
],
|
|
'notificationMessageTemplate' => [
|
|
[
|
|
'id' => 'tmpl-1',
|
|
'displayName' => 'Template 1',
|
|
'@odata.type' => '#microsoft.graph.notificationMessageTemplate',
|
|
],
|
|
],
|
|
]));
|
|
|
|
$runA = executeInventorySyncNow($tenantA, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => [],
|
|
'include_foundations' => true,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($runA['result']['status'] ?? null)->toBe('success');
|
|
expect((int) ($runA['result']['items_observed_count'] ?? 0))->toBe(4);
|
|
expect((int) ($runA['result']['items_upserted_count'] ?? 0))->toBe(4);
|
|
|
|
$runB = executeInventorySyncNow($tenantB, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => [],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($runB['result']['status'] ?? null)->toBe('success');
|
|
expect((int) ($runB['result']['items_observed_count'] ?? 0))->toBe(1);
|
|
expect((int) ($runB['result']['items_upserted_count'] ?? 0))->toBe(1);
|
|
});
|
|
|
|
test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$settingsCatalogLookalike = [
|
|
'id' => 'pol-1',
|
|
'name' => 'Windows 11 SettingsCatalog-Test',
|
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
|
'technologies' => ['mdm'],
|
|
'templateReference' => [
|
|
'templateDisplayName' => 'Windows Security Baseline (name only)',
|
|
],
|
|
];
|
|
|
|
$securityBaseline = [
|
|
'id' => 'pol-2',
|
|
'name' => 'Baseline Policy',
|
|
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
|
'templateReference' => [
|
|
'templateFamily' => 'securityBaseline',
|
|
],
|
|
];
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'settingsCatalogPolicy' => [$settingsCatalogLookalike, $securityBaseline],
|
|
'securityBaselinePolicy' => [$settingsCatalogLookalike, $securityBaseline],
|
|
]));
|
|
|
|
$selection = [
|
|
'policy_types' => ['settingsCatalogPolicy', 'securityBaselinePolicy'],
|
|
'categories' => ['Configuration', 'Endpoint Security'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
executeInventorySyncNow($tenant, $selection);
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'securityBaselinePolicy')
|
|
->where('external_id', 'pol-1')
|
|
->exists())->toBeFalse();
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'settingsCatalogPolicy')
|
|
->where('external_id', 'pol-1')
|
|
->exists())->toBeTrue();
|
|
|
|
expect(\App\Models\InventoryItem::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->where('policy_type', 'securityBaselinePolicy')
|
|
->where('external_id', 'pol-2')
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
test('meta whitelist drops unknown keys without failing', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$sanitizer = app(InventoryMetaSanitizer::class);
|
|
|
|
$meta = $sanitizer->sanitize([
|
|
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
|
'etag' => 'W/\"123\"',
|
|
'scope_tag_ids' => ['0', 'tag-1'],
|
|
'assignment_target_count' => '5',
|
|
'warnings' => ['ok'],
|
|
'unknown_key' => 'should_not_persist',
|
|
]);
|
|
|
|
$item = \App\Models\InventoryItem::query()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'policy_type' => 'deviceConfiguration',
|
|
'external_id' => 'cfg-1',
|
|
'display_name' => 'Config 1',
|
|
'meta_jsonb' => $meta,
|
|
'last_seen_at' => now(),
|
|
'last_seen_run_id' => null,
|
|
]);
|
|
|
|
$item->refresh();
|
|
|
|
$stored = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
|
|
|
expect($stored)->not->toHaveKey('unknown_key');
|
|
expect($stored['assignment_target_count'] ?? null)->toBe(5);
|
|
});
|
|
|
|
test('inventory missing is derived from latest completed run and low confidence on partial runs', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$selection = [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [
|
|
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
|
|
],
|
|
]));
|
|
|
|
executeInventorySyncNow($tenant, $selection);
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
]));
|
|
|
|
executeInventorySyncNow($tenant, $selection);
|
|
|
|
$missingService = app(InventoryMissingService::class);
|
|
$result = $missingService->missingForSelection($tenant, $selection);
|
|
|
|
expect($result['missing'])->toHaveCount(1);
|
|
expect($result['lowConfidence'])->toBeFalse();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
], failedTypes: ['deviceConfiguration']));
|
|
|
|
executeInventorySyncNow($tenant, $selection);
|
|
|
|
$result2 = $missingService->missingForSelection($tenant, $selection);
|
|
expect($result2['missing'])->toHaveCount(1);
|
|
expect($result2['lowConfidence'])->toBeTrue();
|
|
});
|
|
|
|
test('selection isolation: run for selection Y does not affect selection X missing', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$selectionX = [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
$selectionY = [
|
|
'policy_types' => ['deviceCompliancePolicy'],
|
|
'categories' => ['Compliance'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [
|
|
['id' => 'cfg-1', 'displayName' => 'Config 1', '@odata.type' => '#microsoft.graph.deviceConfiguration'],
|
|
],
|
|
'deviceCompliancePolicy' => [
|
|
['id' => 'cmp-1', 'displayName' => 'Compliance 1', '@odata.type' => '#microsoft.graph.deviceCompliancePolicy'],
|
|
],
|
|
]));
|
|
|
|
executeInventorySyncNow($tenant, $selectionX);
|
|
executeInventorySyncNow($tenant, $selectionY);
|
|
|
|
$missingService = app(InventoryMissingService::class);
|
|
$resultX = $missingService->missingForSelection($tenant, $selectionX);
|
|
|
|
expect($resultX['missing'])->toHaveCount(0);
|
|
});
|
|
|
|
test('lock prevents overlapping runs for same tenant and selection', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
]));
|
|
|
|
$selection = [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
];
|
|
|
|
$hash = app(\App\Services\Inventory\InventorySelectionHasher::class)->hash($selection);
|
|
$lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900);
|
|
expect($lock->get())->toBeTrue();
|
|
|
|
$run = executeInventorySyncNow($tenant, $selection);
|
|
|
|
expect($run['result']['status'] ?? null)->toBe('skipped');
|
|
$codes = is_array($run['result']['error_codes'] ?? null) ? $run['result']['error_codes'] : [];
|
|
expect($codes)->toContain('lock_contended');
|
|
|
|
$lock->release();
|
|
});
|
|
|
|
test('inventory sync does not create snapshot or backup rows', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$baseline = [
|
|
'policy_versions' => PolicyVersion::query()->count(),
|
|
'backup_sets' => BackupSet::query()->count(),
|
|
'backup_items' => BackupItem::query()->count(),
|
|
'backup_schedules' => BackupSchedule::query()->count(),
|
|
'backup_schedule_runs' => BackupScheduleRun::query()->count(),
|
|
];
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient([
|
|
'deviceConfiguration' => [],
|
|
]));
|
|
|
|
executeInventorySyncNow($tenant, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect(PolicyVersion::query()->count())->toBe($baseline['policy_versions']);
|
|
expect(BackupSet::query()->count())->toBe($baseline['backup_sets']);
|
|
expect(BackupItem::query()->count())->toBe($baseline['backup_items']);
|
|
expect(BackupSchedule::query()->count())->toBe($baseline['backup_schedules']);
|
|
expect(BackupScheduleRun::query()->count())->toBe($baseline['backup_schedule_runs']);
|
|
});
|
|
|
|
test('run error persistence is safe and does not include bearer tokens', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$throwable = new RuntimeException('Graph failed: Bearer abc.def.ghi');
|
|
|
|
app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable));
|
|
|
|
$run = executeInventorySyncNow($tenant, [
|
|
'policy_types' => ['deviceConfiguration'],
|
|
'categories' => ['Configuration'],
|
|
'include_foundations' => false,
|
|
'include_dependencies' => false,
|
|
]);
|
|
|
|
expect($run['result']['status'] ?? null)->toBe('failed');
|
|
|
|
$context = is_array($run['result']['error_context'] ?? null) ? $run['result']['error_context'] : [];
|
|
$message = (string) ($context['message'] ?? '');
|
|
|
|
expect($message)->not->toContain('abc.def.ghi');
|
|
expect($message)->toContain('Bearer [REDACTED]');
|
|
});
|