Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
## 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
400 lines
15 KiB
PHP
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,
|
|
]);
|
|
});
|