Added `BaselineSubjectResolution` page and supporting logic to visualize missing identities, ambiguous matches, and skipped coverages as defined in Spec 384. Replaces legacy compare warnings with an actionable, deterministic UI surface. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #455
371 lines
16 KiB
PHP
371 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\BaselineSubjectResolution;
|
|
use App\Models\AuditLog;
|
|
use App\Models\ProviderResourceBinding;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Baselines\BaselineSubjectResolutionQuery;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\ManagedEnvironmentLinks;
|
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
|
use App\Support\Navigation\RelatedNavigationResolver;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Resources\ProviderResourceBindingStatus;
|
|
use App\Support\Resources\ProviderResourceDescriptor;
|
|
use App\Support\Resources\ProviderResourceResolutionMode;
|
|
use App\Support\Resources\ResourceIdentity;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Livewire\Livewire;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('renders an environment-scoped actionable subject worklist', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
|
|
spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()])
|
|
->assertOk()
|
|
->assertSee('Baseline subject resolution')
|
|
->assertSee('Duplicate policy')
|
|
->assertSee('Unresolved Duplicate Candidates')
|
|
->assertSee('Binding required')
|
|
->assertSee('TenantPilot-only');
|
|
});
|
|
|
|
it('renders specific empty states for missing compare runs and quiet compare results', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
spec384BaselineSubjectResolutionLivewire($tenant, $user)
|
|
->assertOk()
|
|
->assertSee('Run baseline compare first')
|
|
->assertSee('No baseline compare run exists for this environment yet.');
|
|
|
|
[$quietUser, $quietTenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant);
|
|
$quietRun = seedBaselineCompareRun($quietTenant, $profile, $snapshot, [
|
|
'result_semantics' => [
|
|
'version' => 1,
|
|
'subject_outcomes' => [
|
|
BaselineSubjectResolutionFixtures::semanticOutcome([
|
|
'reason' => 'verified_no_drift',
|
|
'actionability' => 'none',
|
|
'readiness_impact' => 'no_impact',
|
|
'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'],
|
|
]),
|
|
],
|
|
],
|
|
]);
|
|
|
|
spec384BaselineSubjectResolutionLivewire($quietTenant, $quietUser, ['operation_run_id' => (int) $quietRun->getKey()])
|
|
->assertOk()
|
|
->assertSee('No baseline subject decisions required')
|
|
->assertSee('The selected compare context has no unresolved or decision-required baseline subjects.');
|
|
});
|
|
|
|
it('records manual bindings through the confirmed Filament action', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
$row = app(BaselineSubjectResolutionQuery::class)->rows($tenant, [
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
])[0];
|
|
$candidateKey = (string) $row['candidates'][0]['candidate_key'];
|
|
|
|
$component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()]);
|
|
$record = collect($component->instance()->getTableRecords()->items())->first();
|
|
|
|
$component->callTableAction('bindSubject', $record, data: [
|
|
'candidate_key' => $candidateKey,
|
|
'operator_note' => 'Operator selected the matching provider resource after reviewing duplicate candidates.',
|
|
]);
|
|
|
|
$binding = ProviderResourceBinding::query()
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->firstOrFail();
|
|
|
|
expect($binding->resolution_mode)->toBe(ProviderResourceResolutionMode::ManualBinding)
|
|
->and($binding->provider_resource_id)->toBe('candidate-left')
|
|
->and((int) $binding->source_operation_run_id)->toBe((int) $run->getKey());
|
|
|
|
expect(AuditLog::query()
|
|
->where('action', AuditActionId::ProviderResourceBindingCreated->value)
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('records and revokes subject decisions through confirmed Filament actions', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedDecisionIdentityRun($tenant);
|
|
|
|
$component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()]);
|
|
$record = collect($component->instance()->getTableRecords()->items())->first();
|
|
|
|
$component->callTableAction('recordDecision', $record, data: [
|
|
'decision' => 'accepted_limitation',
|
|
'operator_note' => 'Operator accepted this baseline limitation after confirming provider coverage scope.',
|
|
]);
|
|
|
|
$binding = ProviderResourceBinding::query()
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->firstOrFail();
|
|
|
|
expect($binding->resolution_mode)->toBe(ProviderResourceResolutionMode::AcceptedLimitation)
|
|
->and($binding->binding_status)->toBe(ProviderResourceBindingStatus::Active)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::ProviderResourceBindingCreated->value)
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->exists())->toBeTrue();
|
|
|
|
$updatedRecord = collect($component->instance()->getTableRecords()->items())->first();
|
|
|
|
$component
|
|
->assertTableActionVisible('revokeDecision', $updatedRecord)
|
|
->callTableAction('revokeDecision', $updatedRecord, data: [
|
|
'operator_note' => 'Operator revoked the limitation because fresh provider evidence is expected.',
|
|
]);
|
|
|
|
expect($binding->refresh()->binding_status)->toBe(ProviderResourceBindingStatus::Revoked)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::ProviderResourceBindingRevoked->value)
|
|
->where('managed_environment_id', (int) $tenant->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('disables decision actions for workspace members missing manage capability', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'owner', workspaceRole: 'readonly');
|
|
|
|
$component = spec384BaselineSubjectResolutionLivewire($tenant, $readonly, ['operation_run_id' => (int) $run->getKey()]);
|
|
$record = collect($component->instance()->getTableRecords()->items())->first();
|
|
|
|
$component
|
|
->assertTableActionVisible('bindSubject', $record)
|
|
->assertTableActionDisabled('bindSubject', $record);
|
|
});
|
|
|
|
it('returns not found for users outside the workspace/environment scope', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
spec384SeedSubjectResolutionRun($tenant);
|
|
[$outsider] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($outsider)
|
|
->get(ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns not found when the route workspace does not own the environment', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
spec384SeedSubjectResolutionRun($tenant);
|
|
[, $foreignTenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$url = str_replace(
|
|
'/workspaces/'.$tenant->workspace->slug.'/',
|
|
'/workspaces/'.$foreignTenant->workspace->slug.'/',
|
|
ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant),
|
|
);
|
|
|
|
$this->actingAs($owner)
|
|
->get($url)
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('reauthorizes livewire reads after workspace membership changes', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
|
|
$component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()])
|
|
->assertOk();
|
|
|
|
WorkspaceMembership::query()
|
|
->where('workspace_id', (int) $tenant->workspace_id)
|
|
->where('user_id', (int) $user->getKey())
|
|
->delete();
|
|
|
|
app(WorkspaceCapabilityResolver::class)->clearCache();
|
|
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
|
|
|
expect(fn (): mixed => $component->instance()->currentEnvironment())
|
|
->toThrow(NotFoundHttpException::class);
|
|
});
|
|
|
|
it('adds a contextual link from baseline compare only when action is required', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
|
|
baselineCompareLandingLivewire($tenant, user: $user)
|
|
->assertSee('Resolve baseline subjects')
|
|
->assertSee('1 subject need identity or coverage decisions');
|
|
|
|
[$quietUser, $quietTenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant);
|
|
seedBaselineCompareRun($quietTenant, $profile, $snapshot, [
|
|
'result_semantics' => [
|
|
'version' => 1,
|
|
'subject_outcomes' => [
|
|
BaselineSubjectResolutionFixtures::semanticOutcome([
|
|
'reason' => 'verified_no_drift',
|
|
'actionability' => 'none',
|
|
'readiness_impact' => 'no_impact',
|
|
'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'],
|
|
]),
|
|
],
|
|
],
|
|
]);
|
|
|
|
baselineCompareLandingLivewire($quietTenant, user: $quietUser)
|
|
->assertDontSee('Resolve baseline subjects');
|
|
});
|
|
|
|
it('adds a baseline subject resolution link to baseline compare run related links', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
|
|
$links = \App\Support\OperationRunLinks::related($run, $tenant);
|
|
|
|
expect($links)->toHaveKey('Baseline Subject Resolution')
|
|
->and($links['Baseline Subject Resolution'])->toContain('baseline-subject-resolution')
|
|
->and($links['Baseline Subject Resolution'])->toContain('operation_run_id='.(int) $run->getKey());
|
|
|
|
[, $quietTenant] = createUserWithTenant(role: 'owner');
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant);
|
|
$quietRun = seedBaselineCompareRun($quietTenant, $profile, $snapshot, [
|
|
'result_semantics' => [
|
|
'version' => 1,
|
|
'subject_outcomes' => [
|
|
BaselineSubjectResolutionFixtures::semanticOutcome([
|
|
'reason' => 'verified_no_drift',
|
|
'actionability' => 'none',
|
|
'readiness_impact' => 'no_impact',
|
|
'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'],
|
|
]),
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect(\App\Support\OperationRunLinks::related($quietRun, $quietTenant))
|
|
->not->toHaveKey('Baseline Subject Resolution');
|
|
});
|
|
|
|
it('adds a baseline subject resolution entry to operation run detail related navigation', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$this->actingAs($user);
|
|
|
|
$run = spec384SeedSubjectResolutionRun($tenant);
|
|
|
|
$entry = collect(app(RelatedNavigationResolver::class)
|
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run))
|
|
->firstWhere('key', 'baseline_subject_resolution');
|
|
|
|
expect($entry)->not->toBeNull()
|
|
->and($entry['targetUrl'])->toContain('baseline-subject-resolution')
|
|
->and($entry['targetUrl'])->toContain('operation_run_id='.(int) $run->getKey())
|
|
->and($entry['actionLabel'])->toBe('Resolve baseline subjects');
|
|
});
|
|
|
|
function spec384SeedSubjectResolutionRun($tenant)
|
|
{
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
$leftIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-left');
|
|
$rightIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-right');
|
|
$leftDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($leftIdentity, 'Duplicate policy');
|
|
$rightDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($rightIdentity, 'Duplicate policy');
|
|
|
|
BaselineSubjectResolutionFixtures::inventoryCandidate($tenant, $leftIdentity, 'Duplicate policy');
|
|
BaselineSubjectResolutionFixtures::inventoryCandidate($tenant, $rightIdentity, 'Duplicate policy');
|
|
|
|
return seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: [
|
|
'result_semantics' => [
|
|
'version' => 1,
|
|
'subject_outcomes' => [
|
|
BaselineSubjectResolutionFixtures::semanticOutcome([
|
|
'reason' => 'unresolved_duplicate_candidates',
|
|
'actionability' => 'binding_required',
|
|
'readiness_impact' => 'customer_blocker',
|
|
'subject' => [
|
|
'subject_domain' => 'baseline',
|
|
'subject_class' => \App\Support\Baselines\SubjectClass::PolicyBacked->value,
|
|
'subject_type_key' => 'deviceConfiguration',
|
|
'subject_key' => 'legacy-display-key',
|
|
'display_label' => 'Duplicate policy',
|
|
'candidate_descriptors' => [
|
|
$leftDescriptor->toArray(),
|
|
$rightDescriptor->toArray(),
|
|
],
|
|
],
|
|
]),
|
|
],
|
|
],
|
|
],
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
);
|
|
}
|
|
|
|
function spec384SeedDecisionIdentityRun($tenant)
|
|
{
|
|
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
|
|
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'limited-policy');
|
|
$descriptor = ProviderResourceDescriptor::fromIdentity(
|
|
identity: $identity,
|
|
subjectDomain: 'baseline',
|
|
subjectClass: \App\Support\Baselines\SubjectClass::PolicyBacked,
|
|
subjectTypeKey: 'deviceConfiguration',
|
|
displayLabel: 'Accepted limitation policy',
|
|
sourceReferences: [],
|
|
fingerprint: $identity->fingerprint(),
|
|
lastSeenAt: now()->toIso8601String(),
|
|
);
|
|
|
|
return seedBaselineCompareRun(
|
|
tenant: $tenant,
|
|
profile: $profile,
|
|
snapshot: $snapshot,
|
|
compareContext: [
|
|
'result_semantics' => [
|
|
'version' => 1,
|
|
'subject_outcomes' => [
|
|
BaselineSubjectResolutionFixtures::semanticOutcome([
|
|
'reason' => 'foundation_limitation',
|
|
'actionability' => 'decision_required',
|
|
'readiness_impact' => 'internal_blocker',
|
|
'subject' => [
|
|
'subject_domain' => 'baseline',
|
|
'subject_class' => \App\Support\Baselines\SubjectClass::PolicyBacked->value,
|
|
'subject_type_key' => 'deviceConfiguration',
|
|
'subject_key' => 'accepted-limitation-policy',
|
|
'display_label' => 'Accepted limitation policy',
|
|
'provider_resource_descriptor' => $descriptor->toArray(),
|
|
],
|
|
]),
|
|
],
|
|
],
|
|
],
|
|
status: OperationRunStatus::Completed->value,
|
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
|
);
|
|
}
|
|
|
|
function spec384BaselineSubjectResolutionLivewire($tenant, $user, array $queryParams = [])
|
|
{
|
|
$manager = Livewire::withHeaders([
|
|
'Referer' => ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant),
|
|
])->actingAs($user);
|
|
|
|
if ($queryParams !== []) {
|
|
$manager = $manager->withQueryParams($queryParams);
|
|
}
|
|
|
|
return $manager->test(BaselineSubjectResolution::class, ['environment' => $tenant]);
|
|
}
|