feat: Phase 4 US2 baseline capture service + job + tests (T031-T038)
This commit is contained in:
parent
e0800fb42d
commit
cf57d3650a
@ -5,11 +5,17 @@
|
|||||||
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
namespace App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Baselines\BaselineCaptureService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
class ViewBaselineProfile extends ViewRecord
|
class ViewBaselineProfile extends ViewRecord
|
||||||
@ -19,11 +25,95 @@ class ViewBaselineProfile extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
$this->captureAction(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => $this->hasManageCapability()),
|
->visible(fn (): bool => $this->hasManageCapability()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function captureAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('capture')
|
||||||
|
->label('Capture Snapshot')
|
||||||
|
->icon('heroicon-o-camera')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->disabled(fn (): bool => ! $this->hasManageCapability())
|
||||||
|
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Capture Baseline Snapshot')
|
||||||
|
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
|
||||||
|
->form([
|
||||||
|
Select::make('source_tenant_id')
|
||||||
|
->label('Source Tenant')
|
||||||
|
->options(fn (): array => $this->getWorkspaceTenantOptions())
|
||||||
|
->required()
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Permission denied')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getRecord();
|
||||||
|
$sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
|
||||||
|
|
||||||
|
if (! $sourceTenant instanceof Tenant) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Source tenant not found')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $sourceTenant, $user);
|
||||||
|
|
||||||
|
if (! $result['ok']) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot start capture')
|
||||||
|
->body('Reason: ' . str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Capture enqueued')
|
||||||
|
->body('Baseline snapshot capture has been started.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function getWorkspaceTenantOptions(): array
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tenant::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->orderBy('display_name')
|
||||||
|
->pluck('display_name', 'id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
private function hasManageCapability(): bool
|
private function hasManageCapability(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
285
app/Jobs/CaptureBaselineSnapshotJob.php
Normal file
285
app/Jobs/CaptureBaselineSnapshotJob.php
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public OperationRun $run,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
BaselineSnapshotIdentity $identity,
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
OperationRunService $operationRunService,
|
||||||
|
): void {
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
$this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
||||||
|
$sourceTenantId = (int) ($context['source_tenant_id'] ?? 0);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::query()->find($profileId);
|
||||||
|
|
||||||
|
if (! $profile instanceof BaselineProfile) {
|
||||||
|
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceTenant = Tenant::query()->find($sourceTenantId);
|
||||||
|
|
||||||
|
if (! $sourceTenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException("Source Tenant #{$sourceTenantId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $this->operationRun->user_id
|
||||||
|
? User::query()->find($this->operationRun->user_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
|
|
||||||
|
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
|
||||||
|
|
||||||
|
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
|
||||||
|
|
||||||
|
$identityHash = $identity->computeIdentity($snapshotItems);
|
||||||
|
|
||||||
|
$snapshot = $this->findOrCreateSnapshot(
|
||||||
|
$profile,
|
||||||
|
$identityHash,
|
||||||
|
$snapshotItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||||
|
|
||||||
|
if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryCounts = [
|
||||||
|
'total' => count($snapshotItems),
|
||||||
|
'processed' => count($snapshotItems),
|
||||||
|
'succeeded' => count($snapshotItems),
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$updatedContext['result'] = [
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
|
'items_captured' => count($snapshotItems),
|
||||||
|
];
|
||||||
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
|
$this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private function collectSnapshotItems(
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
BaselineScope $scope,
|
||||||
|
BaselineSnapshotIdentity $identity,
|
||||||
|
): array {
|
||||||
|
$query = InventoryItem::query()
|
||||||
|
->where('tenant_id', $sourceTenant->getKey());
|
||||||
|
|
||||||
|
if (! $scope->isEmpty()) {
|
||||||
|
$query->whereIn('policy_type', $scope->policyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
$query->orderBy('policy_type')
|
||||||
|
->orderBy('external_id')
|
||||||
|
->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
|
||||||
|
foreach ($inventoryItems as $inventoryItem) {
|
||||||
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||||
|
$baselineHash = $identity->hashItemContent($metaJsonb);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||||
|
'policy_type' => (string) $inventoryItem->policy_type,
|
||||||
|
'baseline_hash' => $baselineHash,
|
||||||
|
'meta_jsonb' => [
|
||||||
|
'display_name' => $inventoryItem->display_name,
|
||||||
|
'category' => $inventoryItem->category,
|
||||||
|
'platform' => $inventoryItem->platform,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $snapshotItems
|
||||||
|
*/
|
||||||
|
private function findOrCreateSnapshot(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
string $identityHash,
|
||||||
|
array $snapshotItems,
|
||||||
|
): BaselineSnapshot {
|
||||||
|
$existing = BaselineSnapshot::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->where('snapshot_identity_hash', $identityHash)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::create([
|
||||||
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'snapshot_identity_hash' => $identityHash,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'summary_jsonb' => [
|
||||||
|
'total_items' => count($snapshotItems),
|
||||||
|
'policy_type_counts' => $this->countByPolicyType($snapshotItems),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||||
|
$rows = array_map(
|
||||||
|
fn (array $item): array => [
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'subject_type' => $item['subject_type'],
|
||||||
|
'subject_external_id' => $item['subject_external_id'],
|
||||||
|
'policy_type' => $item['policy_type'],
|
||||||
|
'baseline_hash' => $item['baseline_hash'],
|
||||||
|
'meta_jsonb' => json_encode($item['meta_jsonb']),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
$chunk,
|
||||||
|
);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::insert($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{policy_type: string}> $items
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function countByPolicyType(array $items): array
|
||||||
|
{
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$type = (string) $item['policy_type'];
|
||||||
|
$counts[$type] = ($counts[$type] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($counts);
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditStarted(
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineProfile $profile,
|
||||||
|
?User $initiator,
|
||||||
|
): void {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'baseline.capture.started',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $initiator?->id,
|
||||||
|
actorEmail: $initiator?->email,
|
||||||
|
actorName: $initiator?->name,
|
||||||
|
resourceType: 'baseline_profile',
|
||||||
|
resourceId: (string) $profile->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditCompleted(
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineProfile $profile,
|
||||||
|
BaselineSnapshot $snapshot,
|
||||||
|
?User $initiator,
|
||||||
|
array $snapshotItems,
|
||||||
|
): void {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'baseline.capture.completed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
||||||
|
'items_captured' => count($snapshotItems),
|
||||||
|
'was_new_snapshot' => $snapshot->wasRecentlyCreated,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $initiator?->id,
|
||||||
|
actorEmail: $initiator?->email,
|
||||||
|
actorName: $initiator?->name,
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Services/Baselines/BaselineCaptureService.php
Normal file
79
app/Services/Baselines/BaselineCaptureService.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
|
||||||
|
final class BaselineCaptureService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationRunService $runs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||||
|
*/
|
||||||
|
public function startCapture(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
User $initiator,
|
||||||
|
): array {
|
||||||
|
$precondition = $this->validatePreconditions($profile, $sourceTenant);
|
||||||
|
|
||||||
|
if ($precondition !== null) {
|
||||||
|
return ['ok' => false, 'reason_code' => $precondition];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveScope = BaselineScope::fromJsonb(
|
||||||
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
|
'effective_scope' => $effectiveScope->toJsonb(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
|
tenant: $sourceTenant,
|
||||||
|
type: 'baseline_capture',
|
||||||
|
identityInputs: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
],
|
||||||
|
context: $context,
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
CaptureBaselineSnapshotJob::dispatch($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'run' => $run];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string
|
||||||
|
{
|
||||||
|
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
|
||||||
|
return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceTenant->workspace_id === null) {
|
||||||
|
return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $sourceTenant->workspace_id !== (int) $profile->workspace_id) {
|
||||||
|
return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Services/Baselines/BaselineSnapshotIdentity.php
Normal file
59
app/Services/Baselines/BaselineSnapshotIdentity.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
use App\Services\Drift\DriftHasher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the snapshot_identity_hash for baseline snapshot content dedupe.
|
||||||
|
*
|
||||||
|
* The identity hash is a sha256 over normalized snapshot items, enabling
|
||||||
|
* detection of "nothing changed" when capturing the same inventory state.
|
||||||
|
*/
|
||||||
|
final class BaselineSnapshotIdentity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DriftHasher $hasher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute identity hash over a set of snapshot items.
|
||||||
|
*
|
||||||
|
* Each item is represented as an associative array with:
|
||||||
|
* - subject_type, subject_external_id, policy_type, baseline_hash
|
||||||
|
*
|
||||||
|
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string}> $items
|
||||||
|
*/
|
||||||
|
public function computeIdentity(array $items): string
|
||||||
|
{
|
||||||
|
if ($items === []) {
|
||||||
|
return hash('sha256', '[]');
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_map(
|
||||||
|
fn (array $item): string => implode('|', [
|
||||||
|
trim((string) ($item['subject_type'] ?? '')),
|
||||||
|
trim((string) ($item['subject_external_id'] ?? '')),
|
||||||
|
trim((string) ($item['policy_type'] ?? '')),
|
||||||
|
trim((string) ($item['baseline_hash'] ?? '')),
|
||||||
|
]),
|
||||||
|
$items,
|
||||||
|
);
|
||||||
|
|
||||||
|
sort($normalized, SORT_STRING);
|
||||||
|
|
||||||
|
return hash('sha256', implode("\n", $normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a stable content hash for a single inventory item's metadata.
|
||||||
|
*
|
||||||
|
* Strips volatile OData keys and normalizes for stable comparison.
|
||||||
|
*/
|
||||||
|
public function hashItemContent(mixed $metaJsonb): string
|
||||||
|
{
|
||||||
|
return $this->hasher->hashNormalized($metaJsonb);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
tests/Feature/Baselines/BaselineCaptureTest.php
Normal file
349
tests/Feature/Baselines/BaselineCaptureTest.php
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Baselines\BaselineCaptureService;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
// --- T031: Capture enqueue + precondition tests ---
|
||||||
|
|
||||||
|
it('enqueues capture for an active profile and creates an operation run', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var BaselineCaptureService $service */
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeTrue();
|
||||||
|
expect($result)->toHaveKey('run');
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result['run'];
|
||||||
|
expect($run->type)->toBe('baseline_capture');
|
||||||
|
expect($run->status)->toBe('queued');
|
||||||
|
expect($run->tenant_id)->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
|
||||||
|
expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture for a draft profile with reason code', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'status' => BaselineProfile::STATUS_DRAFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture for an archived profile with reason code', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->archived()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capture for a tenant from a different workspace', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
[$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
$result = $service->startCapture($profile, $otherTenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe('baseline.capture.missing_source_tenant');
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- T032: Concurrent capture reuses active run [EC-004] ---
|
||||||
|
|
||||||
|
it('reuses an existing active run for the same profile/tenant instead of creating a new one', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCaptureService::class);
|
||||||
|
|
||||||
|
$result1 = $service->startCapture($profile, $tenant, $user);
|
||||||
|
$result2 = $service->startCapture($profile, $tenant, $user);
|
||||||
|
|
||||||
|
expect($result1['ok'])->toBeTrue();
|
||||||
|
expect($result2['ok'])->toBeTrue();
|
||||||
|
|
||||||
|
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Snapshot dedupe + capture job execution ---
|
||||||
|
|
||||||
|
it('creates a snapshot with items when the capture job executes', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->count(3)->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_capture',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CaptureBaselineSnapshotJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
|
||||||
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
|
expect((int) ($counts['total'] ?? 0))->toBe(3);
|
||||||
|
expect((int) ($counts['succeeded'] ?? 0))->toBe(3);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::query()
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($snapshot)->not->toBeNull();
|
||||||
|
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
|
||||||
|
|
||||||
|
$profile->refresh();
|
||||||
|
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes snapshots when content is unchanged', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->count(2)->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$idService = app(BaselineSnapshotIdentity::class);
|
||||||
|
$auditLogger = app(AuditLogger::class);
|
||||||
|
|
||||||
|
$run1 = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_capture',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job1 = new CaptureBaselineSnapshotJob($run1);
|
||||||
|
$job1->handle($idService, $auditLogger, $opService);
|
||||||
|
|
||||||
|
$snapshotCountAfterFirst = BaselineSnapshot::query()
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($snapshotCountAfterFirst)->toBe(1);
|
||||||
|
|
||||||
|
$run2 = OperationRun::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'baseline_capture',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job2 = new CaptureBaselineSnapshotJob($run2);
|
||||||
|
$job2->handle($idService, $auditLogger, $opService);
|
||||||
|
|
||||||
|
$snapshotCountAfterSecond = BaselineSnapshot::query()
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($snapshotCountAfterSecond)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- EC-005: Empty scope produces empty snapshot without errors ---
|
||||||
|
|
||||||
|
it('captures an empty snapshot when no inventory items match the scope', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_capture',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['nonExistentPolicyType']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CaptureBaselineSnapshotJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
|
||||||
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
|
expect((int) ($counts['total'] ?? 0))->toBe(0);
|
||||||
|
expect((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::query()
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($snapshot)->not->toBeNull();
|
||||||
|
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures all inventory items when scope has empty policy_types (all types)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'policy_type' => 'compliancePolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_capture',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'source_tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => []],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CaptureBaselineSnapshotJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
|
||||||
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
|
expect((int) ($counts['total'] ?? 0))->toBe(2);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::query()
|
||||||
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($snapshot)->not->toBeNull();
|
||||||
|
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(2);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user