Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m12s
Added BaselineSubjectResolution page and supporting logic to visualize missing identities, ambiguous matches, and skipped coverages per Spec 384.
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]);
|
|
}
|