TenantAtlas/apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php
ahmido 12fb5ebb30
Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
feat: add findings hygiene report and control catalog layering (#264)
## Summary
- add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work
- add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring
- align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model
- extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance

## Notes
- validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md`
- this PR targets `dev` from `225-assignment-hygiene`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #264
2026-04-22 12:26:18 +00:00

400 lines
15 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Livewire\Livewire;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function findingsHygieneActingUser(string $role = 'readonly', 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 findingsHygienePage(?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(FindingsHygieneReport::class);
}
function makeFindingsHygieneFinding(Tenant $tenant, array $attributes = []): Finding
{
$subjectDisplayName = $attributes['subject_display_name'] ?? null;
unset($attributes['subject_display_name']);
if (is_string($subjectDisplayName) && $subjectDisplayName !== '') {
$attributes['evidence_jsonb'] = array_merge(
is_array($attributes['evidence_jsonb'] ?? null) ? $attributes['evidence_jsonb'] : [],
['display_name' => $subjectDisplayName],
);
}
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => fake()->uuid(),
'status' => Finding::STATUS_TRIAGED,
], $attributes));
}
function recordFindingsHygieneAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $finding->tenant_id,
'action' => $action,
'status' => 'success',
'resource_type' => 'finding',
'resource_id' => (string) $finding->getKey(),
'summary' => 'Test workflow activity',
'recorded_at' => $recordedAt,
]);
}
it('redirects hygiene report visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the hygiene report route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertNotFound();
});
it('keeps the hygiene report accessible and calm for workspace members with zero visible hygiene scope', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['name' => 'Calm Workspace']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Findings hygiene report')
->assertSee('No visible hygiene issues right now');
});
it('shows visible hygiene findings with reason labels, last activity, and row drilldown into tenant finding detail', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$user, $tenant] = findingsHygieneActingUser();
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenAssignment = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_display_name' => 'Broken Assignment Finding',
]);
$staleInProgress = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_display_name' => 'Stale Progress Finding',
]);
recordFindingsHygieneAudit($staleInProgress, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
findingsHygienePage($user)
->assertCanSeeTableRecords([$brokenAssignment, $staleInProgress])
->assertSee('Broken assignment')
->assertSee('Stale in progress')
->assertSee('Lost Member')
->assertSee('No current tenant membership');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Broken Assignment Finding')
->assertSee('/findings/'.$brokenAssignment->getKey(), false);
});
it('suppresses hidden-tenant rows, counts, and tenant filter values inside an otherwise available report', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active', 'name' => 'Visible Tenant']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Tenant',
]);
createUserWithTenant($hiddenTenant, $user, role: 'readonly', workspaceRole: 'readonly');
$visibleAssignee = User::factory()->create(['name' => 'Visible Assignee']);
createUserWithTenant($visibleTenant, $visibleAssignee, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $visibleTenant->getKey())
->where('user_id', (int) $visibleAssignee->getKey())
->delete();
$hiddenAssignee = User::factory()->create(['name' => 'Hidden Assignee']);
createUserWithTenant($hiddenTenant, $hiddenAssignee, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $hiddenTenant->getKey())
->where('user_id', (int) $hiddenAssignee->getKey())
->delete();
$visibleFinding = makeFindingsHygieneFinding($visibleTenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $visibleAssignee->getKey(),
'subject_display_name' => 'Visible Hygiene Finding',
]);
$hiddenFinding = makeFindingsHygieneFinding($hiddenTenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $hiddenAssignee->getKey(),
'subject_display_name' => 'Hidden Hygiene Finding',
]);
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $hiddenTenant): void {
$mock->shouldReceive('primeMemberships')->atLeast()->once();
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $hiddenTenant): bool {
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
->toContain((int) $tenant->getKey());
return $capability === Capabilities::TENANT_FINDINGS_VIEW
&& (int) $tenant->getKey() === (int) $visibleTenant->getKey();
});
});
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
$component = findingsHygienePage($user)
->assertCanSeeTableRecords([$visibleFinding])
->assertCanNotSeeTableRecords([$hiddenFinding])
->assertDontSee('Hidden Tenant');
expect($component->instance()->summaryCounts())
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 1,
'stale_in_progress_count' => 0,
])
->and($component->instance()->availableFilters())
->toBe([
[
'key' => 'hygiene_scope',
'label' => 'Findings hygiene only',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => [
['value' => (string) $visibleTenant->getKey(), 'label' => $visibleTenant->name],
],
],
]);
});
it('supports fixed reason filters without duplicating a multi-reason finding in the all-issues view', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$user, $tenant] = findingsHygieneActingUser();
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenOnly = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_display_name' => 'Broken Only',
]);
$staleOnly = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(9),
'subject_display_name' => 'Stale Only',
]);
recordFindingsHygieneAudit($staleOnly, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(9));
$brokenAndStale = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_display_name' => 'Broken And Stale',
]);
recordFindingsHygieneAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
$allIssues = findingsHygienePage($user);
$brokenAssignments = findingsHygienePage($user, ['reason' => 'broken_assignment']);
$staleInProgress = findingsHygienePage($user, ['reason' => 'stale_in_progress']);
$allIssues
->assertCanSeeTableRecords([$brokenOnly, $staleOnly, $brokenAndStale])
->assertSee('Broken And Stale');
$brokenAssignments
->assertCanSeeTableRecords([$brokenOnly, $brokenAndStale])
->assertCanNotSeeTableRecords([$staleOnly]);
$staleInProgress
->assertCanSeeTableRecords([$staleOnly, $brokenAndStale])
->assertCanNotSeeTableRecords([$brokenOnly]);
expect($allIssues->instance()->summaryCounts())
->toBe([
'unique_issue_count' => 3,
'broken_assignment_count' => 2,
'stale_in_progress_count' => 2,
])
->and($allIssues->instance()->availableReasonFilters())
->toBe([
[
'key' => 'all',
'label' => 'All issues',
'active' => true,
'badge_count' => 3,
'url' => FindingsHygieneReport::getUrl(panel: 'admin'),
],
[
'key' => 'broken_assignment',
'label' => 'Broken assignment',
'active' => false,
'badge_count' => 2,
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'broken_assignment']),
],
[
'key' => 'stale_in_progress',
'label' => 'Stale in progress',
'active' => false,
'badge_count' => 2,
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'stale_in_progress']),
],
]);
});
it('explains when the active tenant prefilter hides otherwise visible hygiene issues and clears it in place', function (): void {
[$user, $tenantA] = findingsHygieneActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenantA, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenantA->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$tenantAIssue = makeFindingsHygieneFinding($tenantA, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'subject_display_name' => 'Tenant A Issue',
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsHygienePage($user)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanNotSeeTableRecords([$tenantAIssue])
->assertSee('No hygiene issues match this tenant scope')
->assertActionVisible('clear_tenant_filter');
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$tenantAIssue]);
expect($component->instance()->appliedScope())
->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_findings_hygiene_only',
'reason_filter' => 'all',
'reason_filter_label' => 'All issues',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});