Spec 083: Required permissions hardening (canonical /admin/tenants, DB-only, 404 semantics) #101

Merged
ahmido merged 4 commits from 083-required-permissions-hardening into dev 2026-02-08 23:13:26 +00:00
11 changed files with 188 additions and 42 deletions
Showing only changes of commit b990181bab - Show all commits

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');
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Guidance')
->assertSee('Who can fix this?', false)

View File

@ -1,13 +1,21 @@
<?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();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Queue::fake();
assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->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)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('All required permissions are present', false);
@ -61,7 +61,7 @@
->assertDontSee('data-permission-key="Gamma.Manage.All"', false);
$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()
->assertSee('wire:model.live="status"', false);
@ -71,7 +71,7 @@
->assertSee('data-permission-key="Gamma.Manage.All"', false);
$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();
$delegatedResponse
@ -85,7 +85,7 @@
]);
$featureResponse = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}/required-permissions?{$featureQuery}")
->get("/admin/tenants/{$tenant->external_id}/required-permissions?{$featureQuery}")
->assertSuccessful();
$featureResponse
@ -94,7 +94,7 @@
->assertDontSee('data-permission-key="Beta.Read.All"', false);
$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();
$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)
->get("/admin/t/{$tenant->external_id}/required-permissions")
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful()
->assertSee('Blocked', 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))
->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);
});