Implemented the final operator workflow for the Governance Inbox. This includes refactoring the inbox page, updating finding resources, adding UI enforcement policies, updating related blade views, and adding comprehensive tests for operator workflow and scope contracts. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #418
388 lines
15 KiB
PHP
388 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\FindingExceptionDecision;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ManagedEnvironmentTriageReview;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewFingerprint;
|
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Livewire\Livewire;
|
|
|
|
it('documents the Spec 346 governance inbox repo truth and lane contract artifacts', function (): void {
|
|
$repoTruthMap = repo_path('specs/346-governance-inbox-final-operator-workflow/repo-truth-map.md');
|
|
$laneContract = repo_path('specs/346-governance-inbox-final-operator-workflow/contracts/lane-classification.md');
|
|
|
|
expect($repoTruthMap)->toBeFile()
|
|
->and($laneContract)->toBeFile();
|
|
|
|
$repoTruth = (string) file_get_contents($repoTruthMap);
|
|
$laneClassification = (string) file_get_contents($laneContract);
|
|
|
|
expect($repoTruth)
|
|
->toContain('environment_id')
|
|
->toContain('GovernanceDecisionRegisterBuilder')
|
|
->toContain('Review-ready')
|
|
->toContain('Not added.')
|
|
->and($laneClassification)
|
|
->toContain('Needs triage')
|
|
->toContain('Requires decision')
|
|
->toContain('Risk / exception review')
|
|
->toContain('Evidence required')
|
|
->toContain('Blocked');
|
|
});
|
|
|
|
it('renders the Spec 346 operator summary, active lanes, and linked actions before source detail', function (): void {
|
|
[$user, $environmentA, $environmentB] = spec346GovernanceInboxFixture();
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
|
|
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
|
|
|
$response
|
|
->assertOk()
|
|
->assertSee('Governance Inbox')
|
|
->assertSee('Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.')
|
|
->assertSee('Open governance work')
|
|
->assertSee('Primary inbox lanes')
|
|
->assertSee('Needs triage')
|
|
->assertSee('Requires decision')
|
|
->assertSee('Risk / exception review')
|
|
->assertSee('Evidence required')
|
|
->assertSee('Blocked')
|
|
->assertSee('Next recommended action')
|
|
->assertSee('Triage Finding #')
|
|
->assertSee('Linked records')
|
|
->assertSee('Secondary actions')
|
|
->assertSee('More context')
|
|
->assertSee('Open Customer Review Workspace')
|
|
->assertSee('The finding is unassigned and still needs first triage.')
|
|
->assertSee('The finding remains assigned and needs follow-up before it can be cleared.')
|
|
->assertSee('Spec346 accepted-risk support review')
|
|
->assertSee('The operation reached a terminal outcome that still needs monitoring follow-up.')
|
|
->assertSee('The latest review state asks for follow-up before this governance concern is closed.')
|
|
->assertSee('Recently resolved')
|
|
->assertSee('Source detail')
|
|
->assertSee('Diagnostics / source detail')
|
|
->assertDontSee('Decision workbench')
|
|
->assertDontSee('Queue context')
|
|
->assertDontSee('raw payload should stay hidden');
|
|
|
|
$content = (string) $response->getContent();
|
|
|
|
expect(strpos($content, 'Open governance work'))->toBeLessThan(strpos($content, 'Primary inbox lanes'))
|
|
->and(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Source detail'))
|
|
->and(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Recently resolved'));
|
|
|
|
expect($content)->not->toContain('No governance items need attention.');
|
|
|
|
expect($environmentB->workspace_id)->toBe($environmentA->workspace_id);
|
|
});
|
|
|
|
it('normalizes rendered lane, action, badge, and link contracts before Blade consumes them', function (): void {
|
|
[$user, $environmentA] = spec346GovernanceInboxFixture();
|
|
|
|
$this->actingAs($user);
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
|
|
|
|
$instance = Livewire::test(GovernanceInbox::class)
|
|
->instance();
|
|
|
|
$summary = $instance->operatorSummary();
|
|
$lanes = $instance->laneGroups();
|
|
|
|
expect($summary['next_recommended_item'])
|
|
->toBeArray()
|
|
->and($summary['next_recommended_item'])
|
|
->toHaveKeys([
|
|
'headline',
|
|
'lane_label',
|
|
'status_label',
|
|
'environment_label',
|
|
'reason_label',
|
|
'impact_label',
|
|
'primary_action',
|
|
])
|
|
->and($summary['next_recommended_item']['primary_action'])
|
|
->toHaveKeys(['label', 'url']);
|
|
|
|
foreach ($summary['counts'] as $count) {
|
|
expect($count)->toHaveKeys(['key', 'label', 'count', 'description', 'state', 'chip_label']);
|
|
}
|
|
|
|
foreach ($lanes as $lane) {
|
|
expect($lane)->toHaveKeys(['key', 'label', 'anchor_id', 'count', 'items']);
|
|
|
|
foreach ($lane['items'] as $item) {
|
|
expect($item)->toHaveKeys([
|
|
'lane_label',
|
|
'status_label',
|
|
'title',
|
|
'environment_label',
|
|
'reason_heading',
|
|
'reason_label',
|
|
'impact_label',
|
|
'source_label',
|
|
'owner_label',
|
|
'due_label',
|
|
'evidence_label',
|
|
'exception_label',
|
|
'primary_action',
|
|
'secondary_actions',
|
|
'linked_records',
|
|
]);
|
|
|
|
expect($item['primary_action'])->toHaveKeys(['label', 'url']);
|
|
|
|
foreach ([...$item['secondary_actions'], ...$item['linked_records']] as $link) {
|
|
expect($link)->toHaveKeys(['label', 'url']);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('renders the Spec 346 productized empty state without looking like missing data', function (): void {
|
|
$environment = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec346 Empty Environment',
|
|
]);
|
|
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
|
|
|
$this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
|
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
->assertOk()
|
|
->assertSee('Open governance work')
|
|
->assertSee('No governance items need attention.')
|
|
->assertSee('Findings, decisions, accepted-risk reviews, evidence gaps, and review follow-ups will appear here when they need operator attention.')
|
|
->assertSee('Diagnostics / source detail')
|
|
->assertDontSee('Decision workbench')
|
|
->assertDontSee('No records found')
|
|
->assertDontSee('Missing data');
|
|
});
|
|
|
|
it('renders blocked governance follow-up with explicit blocker reason and next action', function (): void {
|
|
$environment = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec346 Blocked Environment',
|
|
]);
|
|
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
|
|
|
OperationRun::factory()
|
|
->forTenant($environment)
|
|
->create([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
|
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
|
|
|
$response
|
|
->assertOk()
|
|
->assertSee('Blocked')
|
|
->assertSee('Blocker')
|
|
->assertSee('The operation reached a terminal outcome that still needs monitoring follow-up.')
|
|
->assertSee('Open operation proof')
|
|
->assertDontSee('No governance items need attention.');
|
|
});
|
|
|
|
it('keeps recently resolved items secondary to active operator work', function (): void {
|
|
[$user, $environment] = spec346RecentlyResolvedOnlyFixture();
|
|
|
|
$response = $this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
|
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
|
|
|
$response
|
|
->assertOk()
|
|
->assertSee('Recently resolved')
|
|
->assertSee('Open recently closed decisions')
|
|
->assertSee('Recently rejected closure reason');
|
|
|
|
$content = (string) $response->getContent();
|
|
|
|
expect(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Recently resolved'));
|
|
});
|
|
|
|
/**
|
|
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment}
|
|
*/
|
|
function spec346GovernanceInboxFixture(): array
|
|
{
|
|
$environmentA = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec346 Environment Alpha',
|
|
'external_id' => 'spec346-environment-alpha',
|
|
]);
|
|
[$user, $environmentA] = createUserWithTenant($environmentA, role: 'owner', workspaceRole: 'owner');
|
|
|
|
$environmentB = ManagedEnvironment::factory()->active()->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'name' => 'Spec346 Environment Beta',
|
|
'external_id' => 'spec346-environment-beta',
|
|
]);
|
|
createUserWithTenant($environmentB, user: $user, role: 'owner', workspaceRole: 'owner');
|
|
|
|
Finding::factory()
|
|
->for($environmentA)
|
|
->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'status' => Finding::STATUS_NEW,
|
|
'owner_user_id' => null,
|
|
'assignee_user_id' => null,
|
|
'evidence_jsonb' => [],
|
|
]);
|
|
|
|
Finding::factory()
|
|
->for($environmentA)
|
|
->assignedTo((int) $user->getKey())
|
|
->ownedBy((int) $user->getKey())
|
|
->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'evidence_jsonb' => [],
|
|
]);
|
|
|
|
Finding::factory()
|
|
->for($environmentB)
|
|
->assignedTo((int) $user->getKey())
|
|
->ownedBy((int) $user->getKey())
|
|
->create([
|
|
'workspace_id' => (int) $environmentB->workspace_id,
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'evidence_jsonb' => [
|
|
'summary' => [
|
|
'kind' => 'policy_snapshot',
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$exceptionFinding = Finding::factory()
|
|
->for($environmentA)
|
|
->riskAccepted()
|
|
->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
]);
|
|
|
|
FindingException::query()->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'managed_environment_id' => (int) $environmentA->getKey(),
|
|
'finding_id' => (int) $exceptionFinding->getKey(),
|
|
'requested_by_user_id' => (int) $user->getKey(),
|
|
'owner_user_id' => (int) $user->getKey(),
|
|
'status' => FindingException::STATUS_PENDING,
|
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
|
'request_reason' => 'Spec346 accepted-risk support review',
|
|
'requested_at' => now()->subDay(),
|
|
'review_due_at' => now()->addDays(3),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
OperationRun::factory()
|
|
->forTenant($environmentA)
|
|
->create([
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => now()->subMinutes(10),
|
|
]);
|
|
|
|
spec346SeedReviewFollowUp($environmentA, $user);
|
|
spec346SeedRecentlyClosedDecision($environmentA, $user, 'Recently rejected closure reason');
|
|
|
|
return [$user, $environmentA, $environmentB];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: User, 1: ManagedEnvironment}
|
|
*/
|
|
function spec346RecentlyResolvedOnlyFixture(): array
|
|
{
|
|
$environment = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec346 Resolved Environment',
|
|
'external_id' => 'spec346-resolved-environment',
|
|
]);
|
|
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
|
|
|
Finding::factory()
|
|
->for($environment)
|
|
->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'status' => Finding::STATUS_NEW,
|
|
'owner_user_id' => null,
|
|
'assignee_user_id' => null,
|
|
'evidence_jsonb' => [],
|
|
]);
|
|
|
|
spec346SeedRecentlyClosedDecision($environment, $user, 'Recently rejected closure reason');
|
|
|
|
return [$user, $environment];
|
|
}
|
|
|
|
function spec346SeedReviewFollowUp(ManagedEnvironment $environment, User $user): void
|
|
{
|
|
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
|
$fingerprints = app(ManagedEnvironmentTriageReviewFingerprint::class);
|
|
$fingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($environment));
|
|
|
|
ManagedEnvironmentTriageReview::factory()
|
|
->for($environment)
|
|
->followUpNeeded()
|
|
->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'reviewed_by_user_id' => (int) $user->getKey(),
|
|
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
'review_fingerprint' => $fingerprint['fingerprint'],
|
|
'review_snapshot' => $fingerprint['snapshot'],
|
|
]);
|
|
}
|
|
|
|
function spec346SeedRecentlyClosedDecision(ManagedEnvironment $environment, User $user, string $reason): void
|
|
{
|
|
$finding = Finding::factory()
|
|
->for($environment)
|
|
->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
]);
|
|
|
|
$exception = FindingException::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'finding_id' => (int) $finding->getKey(),
|
|
'requested_by_user_id' => (int) $user->getKey(),
|
|
'owner_user_id' => (int) $user->getKey(),
|
|
'status' => FindingException::STATUS_REJECTED,
|
|
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
|
'request_reason' => 'Closed exception for Spec346 register follow-up',
|
|
'requested_at' => now()->subDays(5),
|
|
'review_due_at' => now()->subDays(2),
|
|
'rejected_at' => now()->subDays(1),
|
|
'evidence_summary' => ['reference_count' => 0],
|
|
]);
|
|
|
|
$decision = FindingExceptionDecision::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'finding_exception_id' => (int) $exception->getKey(),
|
|
'actor_user_id' => (int) $user->getKey(),
|
|
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
|
|
'reason' => $reason,
|
|
'decided_at' => now()->subDay(),
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$exception->forceFill([
|
|
'current_decision_id' => (int) $decision->getKey(),
|
|
])->save();
|
|
}
|