Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary - add the new admin findings intake queue at `/admin/findings/intake` with fixed `Unassigned` and `Needs triage` views, tenant-safe filtering, claim flow, and continuity into tenant finding detail and `My Findings` - add Spec 222 artifacts (`spec`, `plan`, `tasks`, `research`, `data model`, `quickstart`, contract, checklist) and register the new admin page - fix follow-up regressions uncovered during full-suite validation around findings action-surface declarations, findings list default columns, provider-blocked run messaging, operation catalog aliases, and workspace overview query volume ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php tests/Feature/Findings/FindingsClaimHandoffTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact` ## Notes - Filament remains on v5 with Livewire v4-compatible patterns - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - no new assets or schema changes are introduced Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #260
348 lines
12 KiB
PHP
348 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Facades\Filament;
|
|
use Livewire\Livewire;
|
|
|
|
function findingsIntakeActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
|
|
{
|
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
|
|
|
test()->actingAs($user);
|
|
setAdminPanelContext();
|
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
|
|
return [$user, $tenant];
|
|
}
|
|
|
|
function findingsIntakePage(?User $user = null, array $query = [])
|
|
{
|
|
if ($user instanceof User) {
|
|
test()->actingAs($user);
|
|
}
|
|
|
|
setAdminPanelContext();
|
|
|
|
$factory = $query === []
|
|
? Livewire::actingAs(auth()->user())
|
|
: Livewire::withQueryParams($query)->actingAs(auth()->user());
|
|
|
|
return $factory->test(FindingsIntakeQueue::class);
|
|
}
|
|
|
|
function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
|
|
{
|
|
return Finding::factory()->for($tenant)->create(array_merge([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'assignee_user_id' => null,
|
|
'owner_user_id' => null,
|
|
'subject_external_id' => fake()->uuid(),
|
|
], $attributes));
|
|
}
|
|
|
|
it('shows only visible unassigned open findings and exposes fixed queue view counts', function (): void {
|
|
[$user, $tenantA] = findingsIntakeActingUser();
|
|
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
|
|
|
|
$tenantB = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'name' => 'Tenant Bravo',
|
|
]);
|
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
|
|
$hiddenTenant = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'name' => 'Hidden Tenant',
|
|
]);
|
|
|
|
$otherAssignee = User::factory()->create();
|
|
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
|
|
|
|
$otherOwner = User::factory()->create();
|
|
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
|
|
|
|
$visibleNew = makeIntakeFinding($tenantA, [
|
|
'subject_external_id' => 'visible-new',
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$visibleReopened = makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'visible-reopened',
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => now()->subHours(6),
|
|
'owner_user_id' => (int) $otherOwner->getKey(),
|
|
]);
|
|
|
|
$visibleTriaged = makeIntakeFinding($tenantA, [
|
|
'subject_external_id' => 'visible-triaged',
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
]);
|
|
|
|
$visibleInProgress = makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'visible-progress',
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'due_at' => now()->subDay(),
|
|
]);
|
|
|
|
$assignedOpen = makeIntakeFinding($tenantA, [
|
|
'subject_external_id' => 'assigned-open',
|
|
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
|
]);
|
|
|
|
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'assignee_user_id' => null,
|
|
'subject_external_id' => 'acknowledged',
|
|
]);
|
|
|
|
$terminal = Finding::factory()->for($tenantA)->resolved()->create([
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'assignee_user_id' => null,
|
|
'subject_external_id' => 'terminal',
|
|
]);
|
|
|
|
$hidden = makeIntakeFinding($hiddenTenant, [
|
|
'subject_external_id' => 'hidden-intake',
|
|
]);
|
|
|
|
$component = findingsIntakePage($user)
|
|
->assertCanSeeTableRecords([$visibleNew, $visibleReopened, $visibleTriaged, $visibleInProgress])
|
|
->assertCanNotSeeTableRecords([$assignedOpen, $acknowledged, $terminal, $hidden])
|
|
->assertSee('Owner: '.$otherOwner->name)
|
|
->assertSee('Needs triage')
|
|
->assertSee('Unassigned');
|
|
|
|
expect($component->instance()->summaryCounts())->toBe([
|
|
'visible_unassigned' => 4,
|
|
'visible_needs_triage' => 2,
|
|
'visible_overdue' => 1,
|
|
]);
|
|
|
|
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
|
|
|
expect($queueViews['unassigned']['badge_count'])->toBe(4)
|
|
->and($queueViews['unassigned']['active'])->toBeTrue()
|
|
->and($queueViews['needs_triage']['badge_count'])->toBe(2)
|
|
->and($queueViews['needs_triage']['active'])->toBeFalse();
|
|
});
|
|
|
|
it('defaults to the active tenant prefilter and lets the operator clear it without dropping intake scope', function (): void {
|
|
[$user, $tenantA] = findingsIntakeActingUser();
|
|
|
|
$tenantB = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'name' => 'Beta Tenant',
|
|
]);
|
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
|
|
$findingA = makeIntakeFinding($tenantA, [
|
|
'subject_external_id' => 'tenant-a',
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
$findingB = makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'tenant-b',
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
]);
|
|
|
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
]);
|
|
|
|
$component = findingsIntakePage($user)
|
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
->assertCanSeeTableRecords([$findingB])
|
|
->assertCanNotSeeTableRecords([$findingA])
|
|
->assertActionVisible('clear_tenant_filter');
|
|
|
|
expect($component->instance()->appliedScope())->toBe([
|
|
'workspace_scoped' => true,
|
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
'queue_view' => 'unassigned',
|
|
'queue_view_label' => 'Unassigned',
|
|
'tenant_prefilter_source' => 'active_tenant_context',
|
|
'tenant_label' => $tenantB->name,
|
|
]);
|
|
|
|
$component->callAction('clear_tenant_filter')
|
|
->assertCanSeeTableRecords([$findingA, $findingB]);
|
|
|
|
expect($component->instance()->appliedScope())->toBe([
|
|
'workspace_scoped' => true,
|
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
'queue_view' => 'unassigned',
|
|
'queue_view_label' => 'Unassigned',
|
|
'tenant_prefilter_source' => 'none',
|
|
'tenant_label' => null,
|
|
]);
|
|
});
|
|
|
|
it('keeps the needs triage view active when clearing the tenant prefilter', function (): void {
|
|
[$user, $tenantA] = findingsIntakeActingUser();
|
|
|
|
$tenantB = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'name' => 'Beta Tenant',
|
|
]);
|
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
|
|
$tenantATriage = makeIntakeFinding($tenantA, [
|
|
'subject_external_id' => 'tenant-a-triage',
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
$tenantBTriage = makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'tenant-b-triage',
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => now()->subHour(),
|
|
]);
|
|
$tenantBBacklog = makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'tenant-b-backlog',
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
]);
|
|
|
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
]);
|
|
|
|
$component = findingsIntakePage($user, ['view' => 'needs_triage'])
|
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
->assertCanSeeTableRecords([$tenantBTriage])
|
|
->assertCanNotSeeTableRecords([$tenantATriage, $tenantBBacklog]);
|
|
|
|
expect($component->instance()->appliedScope())->toBe([
|
|
'workspace_scoped' => true,
|
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
'queue_view' => 'needs_triage',
|
|
'queue_view_label' => 'Needs triage',
|
|
'tenant_prefilter_source' => 'active_tenant_context',
|
|
'tenant_label' => $tenantB->name,
|
|
]);
|
|
|
|
$component->callAction('clear_tenant_filter')
|
|
->assertCanSeeTableRecords([$tenantBTriage, $tenantATriage], inOrder: true)
|
|
->assertCanNotSeeTableRecords([$tenantBBacklog]);
|
|
|
|
expect($component->instance()->appliedScope())->toBe([
|
|
'workspace_scoped' => true,
|
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
'queue_view' => 'needs_triage',
|
|
'queue_view_label' => 'Needs triage',
|
|
'tenant_prefilter_source' => 'none',
|
|
'tenant_label' => null,
|
|
]);
|
|
|
|
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
|
|
|
expect($queueViews['unassigned']['active'])->toBeFalse()
|
|
->and($queueViews['needs_triage']['active'])->toBeTrue();
|
|
});
|
|
|
|
it('separates needs triage from the remaining backlog and keeps deterministic urgency ordering', function (): void {
|
|
[$user, $tenant] = findingsIntakeActingUser();
|
|
|
|
$overdue = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'overdue',
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'due_at' => now()->subDay(),
|
|
]);
|
|
|
|
$reopened = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'reopened',
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'reopened_at' => now()->subHours(2),
|
|
'due_at' => now()->addDay(),
|
|
]);
|
|
|
|
$newFinding = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'new-finding',
|
|
'status' => Finding::STATUS_NEW,
|
|
'due_at' => now()->addDays(2),
|
|
]);
|
|
|
|
$remainingBacklog = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'remaining-backlog',
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'due_at' => now()->addHours(12),
|
|
]);
|
|
|
|
$undatedBacklog = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'undated-backlog',
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'due_at' => null,
|
|
]);
|
|
|
|
findingsIntakePage($user)
|
|
->assertCanSeeTableRecords([$overdue, $reopened, $newFinding, $remainingBacklog, $undatedBacklog], inOrder: true);
|
|
|
|
findingsIntakePage($user, ['view' => 'needs_triage'])
|
|
->assertCanSeeTableRecords([$reopened, $newFinding], inOrder: true)
|
|
->assertCanNotSeeTableRecords([$overdue, $remainingBacklog, $undatedBacklog]);
|
|
});
|
|
|
|
it('builds tenant detail drilldowns with intake continuity', function (): void {
|
|
[$user, $tenant] = findingsIntakeActingUser();
|
|
|
|
$finding = makeIntakeFinding($tenant, [
|
|
'subject_external_id' => 'continuity',
|
|
'status' => Finding::STATUS_NEW,
|
|
]);
|
|
|
|
$component = findingsIntakePage($user, [
|
|
'tenant' => (string) $tenant->external_id,
|
|
'view' => 'needs_triage',
|
|
]);
|
|
|
|
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
|
|
|
|
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
|
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+findings+intake');
|
|
|
|
$this->actingAs($user)
|
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
->get($detailUrl)
|
|
->assertOk()
|
|
->assertSee('Back to findings intake');
|
|
});
|
|
|
|
it('renders both intake empty-state branches with the correct single recovery action', function (): void {
|
|
[$user, $tenantA] = findingsIntakeActingUser();
|
|
|
|
$tenantB = Tenant::factory()->create([
|
|
'status' => 'active',
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
'name' => 'Work Tenant',
|
|
]);
|
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
|
|
makeIntakeFinding($tenantB, [
|
|
'subject_external_id' => 'available-elsewhere',
|
|
]);
|
|
|
|
findingsIntakePage($user, [
|
|
'tenant' => (string) $tenantA->external_id,
|
|
])
|
|
->assertSee('No intake findings match this tenant scope')
|
|
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
|
|
|
|
Finding::query()->delete();
|
|
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
|
|
Filament::setTenant(null, true);
|
|
|
|
findingsIntakePage($user)
|
|
->assertSee('Shared intake is clear')
|
|
->assertTableEmptyStateActionsExistInOrder(['open_my_findings_empty']);
|
|
});
|