Baseline governance UX polish + view Infolist #123
277
app/Filament/Pages/BaselineCompareLanding.php
Normal file
277
app/Filament/Pages/BaselineCompareLanding.php
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class BaselineCompareLanding extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Soll vs Ist';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.baseline-compare-landing';
|
||||||
|
|
||||||
|
public ?string $state = null;
|
||||||
|
|
||||||
|
public ?string $message = null;
|
||||||
|
|
||||||
|
public ?string $profileName = null;
|
||||||
|
|
||||||
|
public ?int $profileId = null;
|
||||||
|
|
||||||
|
public ?int $snapshotId = null;
|
||||||
|
|
||||||
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
|
public ?int $findingsCount = null;
|
||||||
|
|
||||||
|
/** @var array<string, int>|null */
|
||||||
|
public ?array $severityCounts = null;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->state = 'no_tenant';
|
||||||
|
$this->message = 'No tenant selected.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
|
$this->state = 'no_assignment';
|
||||||
|
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $assignment->baselineProfile;
|
||||||
|
|
||||||
|
if ($profile === null) {
|
||||||
|
$this->state = 'no_assignment';
|
||||||
|
$this->message = 'The assigned baseline profile no longer exists.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->profileName = (string) $profile->name;
|
||||||
|
$this->profileId = (int) $profile->getKey();
|
||||||
|
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||||
|
|
||||||
|
if ($this->snapshotId === null) {
|
||||||
|
$this->state = 'no_snapshot';
|
||||||
|
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'baseline_compare')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||||
|
$this->state = 'comparing';
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
$this->message = 'A baseline comparison is currently in progress.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
||||||
|
|
||||||
|
$findingsQuery = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey);
|
||||||
|
|
||||||
|
$totalFindings = (int) (clone $findingsQuery)->count();
|
||||||
|
|
||||||
|
if ($totalFindings > 0) {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->findingsCount = $totalFindings;
|
||||||
|
$this->severityCounts = [
|
||||||
|
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
|
||||||
|
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
||||||
|
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun) {
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->findingsCount = 0;
|
||||||
|
$this->operationRunId = (int) $latestRun->getKey();
|
||||||
|
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = 'idle';
|
||||||
|
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->compareNowAction(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareNowAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('compareNow')
|
||||||
|
->label('Compare Now')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Start baseline comparison')
|
||||||
|
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
||||||
|
->visible(fn (): bool => $this->canCompare())
|
||||||
|
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
Notification::make()->title('Not authenticated')->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
Notification::make()->title('No tenant context')->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
if (! ($result['ok'] ?? false)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Cannot start comparison')
|
||||||
|
->body('Reason: ' . ($result['reason_code'] ?? 'unknown'))
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $result['run'] ?? null;
|
||||||
|
|
||||||
|
if ($run instanceof OperationRun) {
|
||||||
|
$this->operationRunId = (int) $run->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = 'comparing';
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Baseline comparison started')
|
||||||
|
->body('A background job will compute drift against the baseline snapshot.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canCompare(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFindingsUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if ($this->operationRunId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
@ -31,6 +32,7 @@ public function getWidgets(): array
|
|||||||
return [
|
return [
|
||||||
DashboardKpis::class,
|
DashboardKpis::class,
|
||||||
NeedsAttention::class,
|
NeedsAttention::class,
|
||||||
|
BaselineCompareNow::class,
|
||||||
RecentDriftFindings::class,
|
RecentDriftFindings::class,
|
||||||
RecentOperations::class,
|
RecentOperations::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,10 +4,21 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
|
namespace App\Filament\Resources\BaselineProfileResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -43,7 +54,193 @@ public function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
|
->headerActions([
|
||||||
|
$this->assignTenantAction(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
$this->removeAssignmentAction(),
|
||||||
|
])
|
||||||
->emptyStateHeading('No tenants assigned')
|
->emptyStateHeading('No tenants assigned')
|
||||||
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.');
|
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
||||||
|
->emptyStateActions([
|
||||||
|
$this->assignTenantAction(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignTenantAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('assign')
|
||||||
|
->label('Assign Tenant')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->form([
|
||||||
|
Select::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->getAvailableTenantOptions())
|
||||||
|
->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->getOwnerRecord();
|
||||||
|
$tenantId = (int) $data['tenant_id'];
|
||||||
|
|
||||||
|
$existing = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof BaselineTenantAssignment) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant already assigned')
|
||||||
|
->body('This tenant already has a baseline assignment in this workspace.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditAssignment($profile, $assignment, $user, 'created');
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Tenant assigned')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeAssignmentAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('remove')
|
||||||
|
->label('Remove')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->visible(fn (): bool => $this->hasManageCapability())
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Remove tenant assignment')
|
||||||
|
->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
|
||||||
|
->action(function (BaselineTenantAssignment $record): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Permission denied')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$this->auditAssignment($profile, $record, $user, 'removed');
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Assignment removed')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function getAvailableTenantOptions(): array
|
||||||
|
{
|
||||||
|
/** @var BaselineProfile $profile */
|
||||||
|
$profile = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$assignedTenantIds = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$query = Tenant::query()
|
||||||
|
->where('workspace_id', $profile->workspace_id)
|
||||||
|
->orderBy('display_name');
|
||||||
|
|
||||||
|
if (! empty($assignedTenantIds)) {
|
||||||
|
$query->whereNotIn('id', $assignedTenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->pluck('display_name', 'id')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditAssignment(
|
||||||
|
BaselineProfile $profile,
|
||||||
|
BaselineTenantAssignment $assignment,
|
||||||
|
User $user,
|
||||||
|
string $action,
|
||||||
|
): void {
|
||||||
|
$workspace = Workspace::query()->find($profile->workspace_id);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->find($assignment->tenant_id);
|
||||||
|
|
||||||
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: 'baseline.assignment.' . $action,
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
|
'tenant_id' => (int) $assignment->tenant_id,
|
||||||
|
'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
|
||||||
|
],
|
||||||
|
actor: $user,
|
||||||
|
resourceType: 'baseline_profile',
|
||||||
|
resourceId: (string) $profile->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasManageCapability(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if ($workspaceId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
app/Filament/Widgets/Dashboard/BaselineCompareNow.php
Normal file
74
app/Filament/Widgets/Dashboard/BaselineCompareNow.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class BaselineCompareNow extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'hasAssignment' => false,
|
||||||
|
'profileName' => null,
|
||||||
|
'findingsCount' => 0,
|
||||||
|
'highCount' => 0,
|
||||||
|
'landingUrl' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->with('baselineProfile')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
||||||
|
return [
|
||||||
|
'hasAssignment' => false,
|
||||||
|
'profileName' => null,
|
||||||
|
'findingsCount' => 0,
|
||||||
|
'highCount' => 0,
|
||||||
|
'landingUrl' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $assignment->baselineProfile;
|
||||||
|
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
||||||
|
|
||||||
|
$findingsQuery = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('status', Finding::STATUS_NEW);
|
||||||
|
|
||||||
|
$findingsCount = (int) (clone $findingsQuery)->count();
|
||||||
|
$highCount = (int) (clone $findingsQuery)
|
||||||
|
->where('severity', Finding::SEVERITY_HIGH)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hasAssignment' => true,
|
||||||
|
'profileName' => (string) $profile->name,
|
||||||
|
'findingsCount' => $findingsCount,
|
||||||
|
'highCount' => $highCount,
|
||||||
|
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
394
app/Jobs/CompareBaselineToTenantJob.php
Normal file
394
app/Jobs/CompareBaselineToTenantJob.php
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Drift\DriftHasher;
|
||||||
|
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 CompareBaselineToTenantJob 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(
|
||||||
|
DriftHasher $driftHasher,
|
||||||
|
BaselineSnapshotIdentity $snapshotIdentity,
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
OperationRunService $operationRunService,
|
||||||
|
): void {
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
|
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
||||||
|
$snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::query()->find($profileId);
|
||||||
|
|
||||||
|
if (! $profile instanceof BaselineProfile) {
|
||||||
|
throw new RuntimeException("BaselineProfile #{$profileId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->find($this->operationRun->tenant_id);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $this->operationRun->user_id
|
||||||
|
? User::query()->find($this->operationRun->user_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
|
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
||||||
|
|
||||||
|
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
||||||
|
|
||||||
|
$baselineItems = $this->loadBaselineItems($snapshotId);
|
||||||
|
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
|
||||||
|
|
||||||
|
$driftResults = $this->computeDrift($baselineItems, $currentItems);
|
||||||
|
|
||||||
|
$upsertedCount = $this->upsertFindings(
|
||||||
|
$driftHasher,
|
||||||
|
$tenant,
|
||||||
|
$profile,
|
||||||
|
$scopeKey,
|
||||||
|
$driftResults,
|
||||||
|
);
|
||||||
|
|
||||||
|
$severityBreakdown = $this->countBySeverity($driftResults);
|
||||||
|
|
||||||
|
$summaryCounts = [
|
||||||
|
'total' => count($driftResults),
|
||||||
|
'processed' => count($driftResults),
|
||||||
|
'succeeded' => $upsertedCount,
|
||||||
|
'failed' => count($driftResults) - $upsertedCount,
|
||||||
|
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
||||||
|
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
||||||
|
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 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'] = [
|
||||||
|
'findings_total' => count($driftResults),
|
||||||
|
'findings_upserted' => $upsertedCount,
|
||||||
|
'severity_breakdown' => $severityBreakdown,
|
||||||
|
];
|
||||||
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
|
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
|
||||||
|
*
|
||||||
|
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private function loadBaselineItems(int $snapshotId): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
BaselineSnapshotItem::query()
|
||||||
|
->where('baseline_snapshot_id', $snapshotId)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk(500, function ($snapshotItems) use (&$items): void {
|
||||||
|
foreach ($snapshotItems as $item) {
|
||||||
|
$key = $item->policy_type . '|' . $item->subject_external_id;
|
||||||
|
$items[$key] = [
|
||||||
|
'subject_type' => (string) $item->subject_type,
|
||||||
|
'subject_external_id' => (string) $item->subject_external_id,
|
||||||
|
'policy_type' => (string) $item->policy_type,
|
||||||
|
'baseline_hash' => (string) $item->baseline_hash,
|
||||||
|
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load current inventory items keyed by "policy_type|external_id".
|
||||||
|
*
|
||||||
|
* @return array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private function loadCurrentInventory(
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineScope $scope,
|
||||||
|
BaselineSnapshotIdentity $snapshotIdentity,
|
||||||
|
): array {
|
||||||
|
$query = InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenant->getKey());
|
||||||
|
|
||||||
|
if (! $scope->isEmpty()) {
|
||||||
|
$query->whereIn('policy_type', $scope->policyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
$query->orderBy('policy_type')
|
||||||
|
->orderBy('external_id')
|
||||||
|
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
|
||||||
|
foreach ($inventoryItems as $inventoryItem) {
|
||||||
|
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||||
|
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
|
||||||
|
|
||||||
|
$key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id;
|
||||||
|
$items[$key] = [
|
||||||
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||||
|
'policy_type' => (string) $inventoryItem->policy_type,
|
||||||
|
'current_hash' => $currentHash,
|
||||||
|
'meta_jsonb' => [
|
||||||
|
'display_name' => $inventoryItem->display_name,
|
||||||
|
'category' => $inventoryItem->category,
|
||||||
|
'platform' => $inventoryItem->platform,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
|
*
|
||||||
|
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||||
|
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
|
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private function computeDrift(array $baselineItems, array $currentItems): array
|
||||||
|
{
|
||||||
|
$drift = [];
|
||||||
|
|
||||||
|
foreach ($baselineItems as $key => $baselineItem) {
|
||||||
|
if (! array_key_exists($key, $currentItems)) {
|
||||||
|
$drift[] = [
|
||||||
|
'change_type' => 'missing_policy',
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'subject_type' => $baselineItem['subject_type'],
|
||||||
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||||
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||||
|
'current_hash' => '',
|
||||||
|
'evidence' => [
|
||||||
|
'change_type' => 'missing_policy',
|
||||||
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
|
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentItem = $currentItems[$key];
|
||||||
|
|
||||||
|
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
|
||||||
|
$drift[] = [
|
||||||
|
'change_type' => 'different_version',
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'subject_type' => $baselineItem['subject_type'],
|
||||||
|
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||||
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||||
|
'current_hash' => $currentItem['current_hash'],
|
||||||
|
'evidence' => [
|
||||||
|
'change_type' => 'different_version',
|
||||||
|
'policy_type' => $baselineItem['policy_type'],
|
||||||
|
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
||||||
|
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||||
|
'current_hash' => $currentItem['current_hash'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($currentItems as $key => $currentItem) {
|
||||||
|
if (! array_key_exists($key, $baselineItems)) {
|
||||||
|
$drift[] = [
|
||||||
|
'change_type' => 'unexpected_policy',
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => $currentItem['subject_external_id'],
|
||||||
|
'policy_type' => $currentItem['policy_type'],
|
||||||
|
'baseline_hash' => '',
|
||||||
|
'current_hash' => $currentItem['current_hash'],
|
||||||
|
'evidence' => [
|
||||||
|
'change_type' => 'unexpected_policy',
|
||||||
|
'policy_type' => $currentItem['policy_type'],
|
||||||
|
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $drift;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert drift findings using stable fingerprints.
|
||||||
|
*
|
||||||
|
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
||||||
|
*/
|
||||||
|
private function upsertFindings(
|
||||||
|
DriftHasher $driftHasher,
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineProfile $profile,
|
||||||
|
string $scopeKey,
|
||||||
|
array $driftResults,
|
||||||
|
): int {
|
||||||
|
$upsertedCount = 0;
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
foreach ($driftResults as $driftItem) {
|
||||||
|
$fingerprint = $driftHasher->fingerprint(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
scopeKey: $scopeKey,
|
||||||
|
subjectType: $driftItem['subject_type'],
|
||||||
|
subjectExternalId: $driftItem['subject_external_id'],
|
||||||
|
changeType: $driftItem['change_type'],
|
||||||
|
baselineHash: $driftItem['baseline_hash'],
|
||||||
|
currentHash: $driftItem['current_hash'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Finding::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'source' => 'baseline.compare',
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'subject_type' => $driftItem['subject_type'],
|
||||||
|
'subject_external_id' => $driftItem['subject_external_id'],
|
||||||
|
'severity' => $driftItem['severity'],
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'evidence_jsonb' => $driftItem['evidence'],
|
||||||
|
'baseline_operation_run_id' => null,
|
||||||
|
'current_operation_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$upsertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $upsertedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{severity: string}> $driftResults
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function countBySeverity(array $driftResults): array
|
||||||
|
{
|
||||||
|
$counts = [];
|
||||||
|
|
||||||
|
foreach ($driftResults as $item) {
|
||||||
|
$severity = $item['severity'];
|
||||||
|
$counts[$severity] = ($counts[$severity] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditStarted(
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
Tenant $tenant,
|
||||||
|
BaselineProfile $profile,
|
||||||
|
?User $initiator,
|
||||||
|
): void {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'baseline.compare.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,
|
||||||
|
?User $initiator,
|
||||||
|
array $summaryCounts,
|
||||||
|
): void {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'baseline.compare.completed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_profile_name' => (string) $profile->name,
|
||||||
|
'findings_total' => $summaryCounts['total'] ?? 0,
|
||||||
|
'high' => $summaryCounts['high'] ?? 0,
|
||||||
|
'medium' => $summaryCounts['medium'] ?? 0,
|
||||||
|
'low' => $summaryCounts['low'] ?? 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $initiator?->id,
|
||||||
|
actorEmail: $initiator?->email,
|
||||||
|
actorName: $initiator?->name,
|
||||||
|
resourceType: 'operation_run',
|
||||||
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Services/Baselines/BaselineCompareService.php
Normal file
97
app/Services/Baselines/BaselineCompareService.php
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Baselines;
|
||||||
|
|
||||||
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
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 BaselineCompareService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OperationRunService $runs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||||
|
*/
|
||||||
|
public function startCompare(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $initiator,
|
||||||
|
): array {
|
||||||
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->where('workspace_id', $tenant->workspace_id)
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
|
if (! $profile instanceof BaselineProfile) {
|
||||||
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||||
|
}
|
||||||
|
|
||||||
|
$precondition = $this->validatePreconditions($profile);
|
||||||
|
|
||||||
|
if ($precondition !== null) {
|
||||||
|
return ['ok' => false, 'reason_code' => $precondition];
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshotId = (int) $profile->active_snapshot_id;
|
||||||
|
|
||||||
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
|
);
|
||||||
|
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||||
|
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
|
'effective_scope' => $effectiveScope->toJsonb(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
],
|
||||||
|
context: $context,
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
CompareBaselineToTenantJob::dispatch($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'run' => $run];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||||
|
{
|
||||||
|
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
|
||||||
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($profile->active_snapshot_id === null) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Compare the current tenant state against the assigned baseline profile to detect drift.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($profileName))
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="font-medium text-gray-950 dark:text-white">Baseline Profile:</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-200">{{ $profileName }}</span>
|
||||||
|
@if ($snapshotId)
|
||||||
|
<x-filament::badge color="success" size="sm">
|
||||||
|
Snapshot #{{ $snapshotId }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($state === 'no_tenant')
|
||||||
|
<x-filament::badge color="gray">No tenant selected</x-filament::badge>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
@elseif ($state === 'no_assignment')
|
||||||
|
<x-filament::badge color="gray">No baseline assigned</x-filament::badge>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
@elseif ($state === 'no_snapshot')
|
||||||
|
<x-filament::badge color="warning">No snapshot available</x-filament::badge>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
@elseif ($state === 'comparing')
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<x-filament::badge color="info">Comparing…</x-filament::badge>
|
||||||
|
<x-filament::loading-indicator class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
|
||||||
|
@if ($this->getRunUrl())
|
||||||
|
<x-filament::link :href="$this->getRunUrl()" size="sm">
|
||||||
|
View operation run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'idle')
|
||||||
|
<x-filament::badge color="gray">Ready to compare</x-filament::badge>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
@elseif ($state === 'ready')
|
||||||
|
@if ($findingsCount !== null && $findingsCount > 0)
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (($severityCounts['high'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $severityCounts['high'] }} high
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (($severityCounts['medium'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
{{ $severityCounts['medium'] }} medium
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (($severityCounts['low'] ?? 0) > 0)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ $severityCounts['low'] }} low
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($this->getFindingsUrl())
|
||||||
|
<x-filament::link :href="$this->getFindingsUrl()" size="sm">
|
||||||
|
View findings
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="success" size="sm">
|
||||||
|
No drift detected
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($this->getRunUrl())
|
||||||
|
<x-filament::link :href="$this->getRunUrl()" size="sm">
|
||||||
|
View last run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</x-filament::page>
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
<div class="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
Soll vs Ist
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $hasAssignment)
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No baseline profile assigned yet.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Baseline: <span class="font-medium">{{ $profileName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($findingsCount > 0)
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($highCount > 0)
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
{{ $highCount }} high
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="success" size="sm">
|
||||||
|
No open drift
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($landingUrl)
|
||||||
|
<x-filament::link :href="$landingUrl" size="sm">
|
||||||
|
Go to Soll vs Ist
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
126
tests/Feature/Baselines/BaselineAssignmentTest.php
Normal file
126
tests/Feature/Baselines/BaselineAssignmentTest.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
|
||||||
|
// --- T039: Assignment CRUD tests (RBAC + uniqueness) ---
|
||||||
|
|
||||||
|
it('creates a tenant assignment with workspace context', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($assignment)->toBeInstanceOf(BaselineTenantAssignment::class);
|
||||||
|
expect($assignment->workspace_id)->toBe((int) $tenant->workspace_id);
|
||||||
|
expect($assignment->tenant_id)->toBe((int) $tenant->getKey());
|
||||||
|
expect($assignment->baseline_profile_id)->toBe((int) $profile->getKey());
|
||||||
|
expect($assignment->assigned_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents duplicate assignments for the same workspace+tenant', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile1 = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
$profile2 = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile1->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attempting to assign the same tenant in the same workspace should fail
|
||||||
|
// due to the unique constraint on (workspace_id, tenant_id)
|
||||||
|
expect(fn () => BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile2->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]))->toThrow(\Illuminate\Database\QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same tenant to be assigned in different workspaces', function () {
|
||||||
|
[$user1, $tenant1] = createUserWithTenant(role: 'owner');
|
||||||
|
[$user2, $tenant2] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile1 = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant1->workspace_id,
|
||||||
|
]);
|
||||||
|
$profile2 = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant2->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$a1 = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant1->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant1->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile1->getKey(),
|
||||||
|
]);
|
||||||
|
$a2 = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant2->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant2->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile2->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($a1->exists)->toBeTrue();
|
||||||
|
expect($a2->exists)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an assignment without deleting related models', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'assigned_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignmentId = $assignment->getKey();
|
||||||
|
$assignment->delete();
|
||||||
|
|
||||||
|
expect(BaselineTenantAssignment::query()->find($assignmentId))->toBeNull();
|
||||||
|
expect(BaselineProfile::query()->find($profile->getKey()))->not->toBeNull();
|
||||||
|
expect(Tenant::query()->find($tenant->getKey()))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the baseline profile relationship from assignment', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'name' => 'Test Profile',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignment = BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loaded = $assignment->baselineProfile;
|
||||||
|
|
||||||
|
expect($loaded)->not->toBeNull();
|
||||||
|
expect($loaded->name)->toBe('Test Profile');
|
||||||
|
});
|
||||||
414
tests/Feature/Baselines/BaselineCompareFindingsTest.php
Normal file
414
tests/Feature/Baselines/BaselineCompareFindingsTest.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Drift\DriftHasher;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
|
||||||
|
// --- T041: Compare idempotent finding fingerprint tests ---
|
||||||
|
|
||||||
|
it('creates drift findings when baseline and tenant inventory differ', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
// Baseline has policyA and policyB
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'policy-a-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'content-a'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Policy A'],
|
||||||
|
]);
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'policy-b-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'content-b'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Policy B'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tenant has policyA (different content) and policyC (unexpected)
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'policy-a-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['different_content' => true],
|
||||||
|
'display_name' => 'Policy A modified',
|
||||||
|
]);
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'policy-c-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['new_policy' => true],
|
||||||
|
'display_name' => 'Policy C unexpected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CompareBaselineToTenantJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('succeeded');
|
||||||
|
|
||||||
|
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
||||||
|
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
|
||||||
|
expect($findings->count())->toBe(3);
|
||||||
|
|
||||||
|
$severities = $findings->pluck('severity')->sort()->values()->all();
|
||||||
|
expect($severities)->toContain(Finding::SEVERITY_HIGH);
|
||||||
|
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
|
||||||
|
expect($severities)->toContain(Finding::SEVERITY_LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'policy-x-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'baseline-content'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Policy X'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tenant does NOT have policy-x → missing_policy finding
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
// First run
|
||||||
|
$run1 = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job1 = new CompareBaselineToTenantJob($run1);
|
||||||
|
$job1->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$scopeKey = 'baseline_profile:' . $profile->getKey();
|
||||||
|
$countAfterFirst = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($countAfterFirst)->toBe(1);
|
||||||
|
|
||||||
|
// Second run - new OperationRun so we can dispatch again
|
||||||
|
// Mark first run as completed so ensureRunWithIdentity creates a new one
|
||||||
|
$run1->update(['status' => 'completed', 'completed_at' => now()]);
|
||||||
|
|
||||||
|
$run2 = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job2 = new CompareBaselineToTenantJob($run2);
|
||||||
|
$job2->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$countAfterSecond = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Same fingerprint → same finding updated, not duplicated
|
||||||
|
expect($countAfterSecond)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates zero findings when baseline matches tenant inventory exactly', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
// Baseline item
|
||||||
|
$metaContent = ['policy_key' => 'value123'];
|
||||||
|
$driftHasher = app(DriftHasher::class);
|
||||||
|
$contentHash = $driftHasher->hashNormalized($metaContent);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'matching-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => $contentHash,
|
||||||
|
'meta_jsonb' => ['display_name' => 'Matching Policy'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tenant inventory with same content → same hash
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'matching-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => $metaContent,
|
||||||
|
'display_name' => 'Matching Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CompareBaselineToTenantJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
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'] ?? -1))->toBe(0);
|
||||||
|
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($findings)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- T042: Summary counts severity breakdown tests ---
|
||||||
|
|
||||||
|
it('writes severity breakdown in summary_counts', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
// 2 baseline items: one will be missing (high), one will be different (medium)
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'missing-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'missing-content'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Missing Policy'],
|
||||||
|
]);
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'changed-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'original-content'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Changed Policy'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tenant only has changed-uuid with different content + extra-uuid (unexpected)
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'changed-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['modified_content' => true],
|
||||||
|
'display_name' => 'Changed Policy',
|
||||||
|
]);
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'extra-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['extra_content' => true],
|
||||||
|
'display_name' => 'Extra Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CompareBaselineToTenantJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
|
|
||||||
|
expect((int) ($counts['total'] ?? -1))->toBe(3);
|
||||||
|
expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid
|
||||||
|
expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid
|
||||||
|
expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes result context with findings breakdown', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
// One missing policy
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => 'gone-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => hash('sha256', 'gone-content'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$run = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'baseline_compare',
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new CompareBaselineToTenantJob($run);
|
||||||
|
$job->handle(
|
||||||
|
app(DriftHasher::class),
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
$result = $context['result'] ?? [];
|
||||||
|
|
||||||
|
expect($result)->toHaveKey('findings_total');
|
||||||
|
expect($result)->toHaveKey('findings_upserted');
|
||||||
|
expect($result)->toHaveKey('severity_breakdown');
|
||||||
|
expect((int) $result['findings_total'])->toBe(1);
|
||||||
|
expect((int) $result['findings_upserted'])->toBe(1);
|
||||||
|
});
|
||||||
178
tests/Feature/Baselines/BaselineComparePreconditionsTest.php
Normal file
178
tests/Feature/Baselines/BaselineComparePreconditionsTest.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
// --- T040: Compare precondition 422 tests ---
|
||||||
|
|
||||||
|
it('rejects compare when tenant has no baseline assignment', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compare when assigned profile is in draft status', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'status' => BaselineProfile::STATUS_DRAFT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compare when assigned profile is archived [EC-001]', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->archived()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compare when profile has no active snapshot', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'active_snapshot_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueues compare successfully when all preconditions are met', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeTrue();
|
||||||
|
expect($result)->toHaveKey('run');
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result['run'];
|
||||||
|
expect($run->type)->toBe('baseline_compare');
|
||||||
|
expect($run->status)->toBe('queued');
|
||||||
|
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
|
||||||
|
expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey());
|
||||||
|
|
||||||
|
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- EC-004: Concurrent compare reuses active run ---
|
||||||
|
|
||||||
|
it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
|
||||||
|
$result1 = $service->startCompare($tenant, $user);
|
||||||
|
$result2 = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result1['ok'])->toBeTrue();
|
||||||
|
expect($result2['ok'])->toBeTrue();
|
||||||
|
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user