test: cover required permissions hardening routes and freshness

This commit is contained in:
Ahmed Darrazi 2026-02-08 23:16:14 +01:00
parent 43dff0f2f4
commit b990181bab
11 changed files with 188 additions and 42 deletions

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('returns 200 for tenant-entitled readonly members on the canonical required permissions route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertOk();
});
it('returns 404 for workspace members without tenant entitlement on the canonical route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 404 for users who are not workspace members', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Tenant::query()->whereKey((int) $tenant->getKey())->update(['is_current' => true]);
$this->actingAs($user)
->get('/admin/tenants/invalid-tenant-id/required-permissions')
->assertNotFound();
});

View File

@ -13,7 +13,7 @@
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('Guidance') ->assertSee('Guidance')
->assertSee('Who can fix this?', false) ->assertSee('Who can fix this?', false)

View File

@ -1,13 +1,21 @@
<?php <?php
it('renders the required permissions page without Graph or outbound HTTP calls', function (): void { declare(strict_types=1);
use Illuminate\Support\Facades\Queue;
it('renders the canonical required permissions page without Graph, outbound HTTP, or queue dispatches', function (): void {
bindFailHardGraphClient(); bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
Queue::fake();
assertNoOutboundHttp(function () use ($user, $tenant): void { assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful(); ->assertSuccessful();
}); });
Queue::assertNothingPushed();
}); });

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Keine Daten verfügbar')
->assertSee('/admin/onboarding', false)
->assertSee('Start verification');
});

View File

@ -51,7 +51,7 @@
]); ]);
$missingResponse = $this->actingAs($user) $missingResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('All required permissions are present', false); ->assertSee('All required permissions are present', false);
@ -61,7 +61,7 @@
->assertDontSee('data-permission-key="Gamma.Manage.All"', false); ->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
$presentResponse = $this->actingAs($user) $presentResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present")
->assertSuccessful() ->assertSuccessful()
->assertSee('wire:model.live="status"', false); ->assertSee('wire:model.live="status"', false);
@ -71,7 +71,7 @@
->assertSee('data-permission-key="Gamma.Manage.All"', false); ->assertSee('data-permission-key="Gamma.Manage.All"', false);
$delegatedResponse = $this->actingAs($user) $delegatedResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=present&type=delegated") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=delegated")
->assertSuccessful(); ->assertSuccessful();
$delegatedResponse $delegatedResponse
@ -85,7 +85,7 @@
]); ]);
$featureResponse = $this->actingAs($user) $featureResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
->assertSuccessful(); ->assertSuccessful();
$featureResponse $featureResponse
@ -94,7 +94,7 @@
->assertDontSee('data-permission-key="Beta.Read.All"', false); ->assertDontSee('data-permission-key="Beta.Read.All"', false);
$searchResponse = $this->actingAs($user) $searchResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?status=all&search=delegated") ->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all&search=delegated")
->assertSuccessful(); ->assertSuccessful();
$searchResponse $searchResponse

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
it('returns 404 for the legacy tenant-plane required permissions route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->assertNotFound();
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Re-run verification')
->assertSee('/admin/onboarding', false)
->assertDontSee('/admin/t/', false);
});
it('renders sections in summary-issues-passed-technical order and keeps technical details collapsed by default', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
->assertSee('<details data-testid="technical-details"', false)
->assertDontSee('data-testid="technical-details" open', false);
});

View File

@ -26,7 +26,7 @@
]); ]);
$this->actingAs($user) $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('Blocked', false) ->assertSee('Blocked', false)
->assertSee('applyFeatureFilter', false) ->assertSee('applyFeatureFilter', false)

View File

@ -1,33 +0,0 @@
<?php
use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
it('returns 404 for non-members accessing required permissions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->actingAs($user)
->get("/admin/t/{$otherTenant->external_id}/required-permissions")
->assertNotFound();
});
it('returns 403 for members without tenant.view capability accessing required permissions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->mock(CapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('isMember')
->andReturn(true);
$mock->shouldReceive('can')
->andReturnUsing(fn ($user, $tenant, $capability): bool => $capability !== Capabilities::TENANT_VIEW);
});
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->assertForbidden();
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use Carbon\CarbonImmutable;
it('marks freshness as stale when last refreshed is missing', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
null,
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['last_refreshed_at'])->toBeNull()
->and($freshness['is_stale'])->toBeTrue();
});
it('marks freshness as stale when last refreshed is older than 30 days', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
CarbonImmutable::parse('2026-01-08 11:59:59'),
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['is_stale'])->toBeTrue();
});
it('marks freshness as not stale when last refreshed is exactly 30 days old', function (): void {
$freshness = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
CarbonImmutable::parse('2026-01-09 12:00:00'),
CarbonImmutable::parse('2026-02-08 12:00:00'),
);
expect($freshness['is_stale'])->toBeFalse();
});

View File

@ -98,3 +98,27 @@
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows)) expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows))
->toBe(VerificationReportOverall::Ready->value); ->toBe(VerificationReportOverall::Ready->value);
}); });
it('maps overall to needs_attention when freshness is stale without explicit permission gaps', function (): void {
$rows = [
[
'key' => 'A',
'type' => 'application',
'description' => null,
'features' => ['backup'],
'status' => 'granted',
'details' => null,
],
[
'key' => 'B',
'type' => 'delegated',
'description' => null,
'features' => ['backup'],
'status' => 'granted',
'details' => null,
],
];
expect(TenantRequiredPermissionsViewModelBuilder::deriveOverallStatus($rows, true))
->toBe(VerificationReportOverall::NeedsAttention->value);
});