TenantAtlas/apps/platform/tests/Feature/TenantConfiguration/Spec424SecurityDefaultsCaptureReadinessTest.php
ahmido 2cd512915a feat: complete spec 424 security defaults content-backed comparable support (#491)
Implements spec 424 with comparable renderable capture/readiness changes and supporting tests/spec artifacts.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #491
2026-07-01 14:41:24 +00:00

524 lines
23 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\User;
use App\Services\Auth\ManagedEnvironmentAccessDecision;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\RestoreTier;
use App\Support\TenantConfiguration\SourceClass;
use App\Support\TenantConfiguration\SupportState;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Queue;
it('Spec424 syncs Security Defaults as one active graph v1 fallback registry row', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$rows = TenantConfigurationResourceType::query()
->active()
->where('canonical_type', 'securityDefaults')
->get();
$type = $rows->sole();
expect($rows)->toHaveCount(1)
->and($type->source_class)->toBe(SourceClass::GraphV1Fallback)
->and($type->support_state)->toBe(SupportState::FallbackSupported)
->and($type->default_coverage_level)->toBe(CoverageLevel::Renderable)
->and($type->default_evidence_state)->toBe(EvidenceState::NotCaptured)
->and($type->default_identity_state)->toBe(IdentityState::Stable)
->and($type->default_claim_state)->toBe(ClaimState::InternalOnly)
->and($type->restore_tier)->toBe(RestoreTier::NotRestorable)
->and((bool) $type->allows_beta_claims)->toBeFalse()
->and((bool) $type->allows_graph_fallback_claims)->toBeTrue()
->and((bool) $type->allows_certified_claims)->toBeFalse()
->and($type->metadata['registry_only'])->toBeFalse()
->and($type->metadata['source_contract_key'])->toBe('securityDefaults')
->and($type->metadata['source_version'])->toBe('v1.0')
->and($type->metadata['customer_claims_allowed'])->toBeFalse()
->and($type->metadata['certification_allowed'])->toBeFalse()
->and($type->metadata['restore_allowed'])->toBeFalse();
expect(TenantConfigurationResourceType::query()
->active()
->where('canonical_type', 'securityDefaults')
->where('source_class', SourceClass::Tcm->value)
->count())->toBe(0);
});
it('Spec424 default sync command deactivates stale Security Defaults TCM planning rows', function (): void {
TenantConfigurationResourceType::query()->updateOrCreate(
[
'canonical_type' => 'securityDefaults',
'source_class' => SourceClass::Tcm->value,
],
[
'display_name' => 'Security defaults legacy planning row',
'description' => 'Legacy Security Defaults planning row.',
'workload' => 'entra',
'resource_class' => 'configuration',
'support_state' => SupportState::OutOfScope->value,
'default_coverage_level' => CoverageLevel::Detected->value,
'default_evidence_state' => EvidenceState::NotCaptured->value,
'default_identity_state' => IdentityState::Derived->value,
'default_claim_state' => ClaimState::InternalOnly->value,
'restore_tier' => RestoreTier::NotRestorable->value,
'allows_beta_claims' => false,
'allows_graph_fallback_claims' => false,
'allows_certified_claims' => false,
'is_active' => true,
'metadata' => ['catalog_import_batch' => 'spec_419_seeded_representative_manifest'],
],
);
$this->artisan('tenant-configuration:sync-defaults')
->assertSuccessful();
$activeRows = TenantConfigurationResourceType::query()
->active()
->where('canonical_type', 'securityDefaults')
->get();
$activeType = $activeRows->sole();
expect($activeRows)->toHaveCount(1)
->and($activeType->source_class)->toBe(SourceClass::GraphV1Fallback)
->and(TenantConfigurationResourceType::query()
->where('canonical_type', 'securityDefaults')
->where('source_class', SourceClass::Tcm->value)
->where('is_active', true)
->exists())->toBeFalse();
});
it('Spec424 captures singleton Security Defaults evidence with internal-only renderable posture', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'scopes_granted' => ['Policy.Read.All'],
]);
$graph = spec424CaptureGraphClient([
spec424CaptureSecurityDefaultsPayload([
'@odata.context' => 'https://graph.microsoft.com/v1.0/$metadata#policies/identitySecurityDefaultsEnforcementPolicy/$entity',
'clientSecret' => 'spec424-provider-secret',
]),
]);
app()->instance(GraphClientInterface::class, $graph);
$result = app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: spec424CaptureRun($user, $environment, $connection),
canonicalTypes: ['securityDefaults'],
);
expect($graph->calls)->toHaveCount(1)
->and($graph->calls[0]['policy_type'])->toBe('securityDefaults')
->and($graph->calls[0]['options'])->toHaveKey('client_request_id')
->and($graph->calls[0]['options'])->not->toHaveKey('top')
->and($result['summary_counts'])->toMatchArray([
'total' => 1,
'processed' => 1,
'succeeded' => 1,
'skipped' => 0,
'failed' => 0,
])
->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::Captured->value)
->and($result['outcomes'][0]['item_count'])->toBe(1)
->and($result['outcomes'][0]['source_contract_key'])->toBe('securityDefaults');
$resource = TenantConfigurationResource::query()->sole();
$evidence = TenantConfigurationResourceEvidence::query()->sole();
expect($resource->canonical_type)->toBe('securityDefaults')
->and($resource->source_class)->toBe(SourceClass::GraphV1Fallback)
->and($resource->canonical_key_kind)->toBe(CanonicalKeyKind::GraphObjectId)
->and($resource->source_resource_id)->toBe('securityDefaults')
->and($resource->source_display_name)->toBe('Security Defaults')
->and($resource->latest_identity_state)->toBe(IdentityState::Stable)
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
->and($resource->source_identity['strategy_identifier'])->toBe('graph.security_defaults.v1')
->and($resource->source_metadata['source_contract_key'])->toBe('securityDefaults')
->and($resource->source_metadata['registry_source_class'])->toBe('graph_v1_fallback')
->and($resource->source_metadata['registry_support_state'])->toBe('fallback_supported');
expect($evidence->source_contract_key)->toBe('securityDefaults')
->and($evidence->source_endpoint)->toBe('/policies/identitySecurityDefaultsEnforcementPolicy')
->and($evidence->source_version)->toBe('v1.0')
->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
->and($evidence->raw_payload['clientSecret'])->toBe('spec424-provider-secret')
->and($evidence->normalized_payload)->not->toHaveKey('@odata.context')
->and($evidence->normalized_payload['clientSecret'])->toBe('[redacted]')
->and($evidence->permission_context['scopes_granted'])->toBe(['Policy.Read.All'])
->and($evidence->payload_hash)->toBeString()->toHaveLength(64);
});
it('Spec424 creates no fake evidence when Security Defaults capture is permission blocked', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'scopes_granted' => [],
]);
$graph = spec424CaptureGraphClient([
new GraphResponse(false, [], 403, [['message' => 'Forbidden']]),
]);
app()->instance(GraphClientInterface::class, $graph);
$result = app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: spec424CaptureRun($user, $environment, $connection),
canonicalTypes: ['securityDefaults'],
);
expect($graph->calls)->toHaveCount(1)
->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::BlockedPermission->value)
->and($result['outcomes'][0]['reason_code'])->toBe('graph_permission_blocked')
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec424 blocks mismatched operation run scope before provider work', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
$graph = spec424CaptureGraphClient([
spec424CaptureSecurityDefaultsPayload(),
]);
app()->instance(GraphClientInterface::class, $graph);
$run = spec424CaptureRun($user, $environment, $connection, [
'securityDefaults',
], [
'provider_connection_id' => (int) $connection->getKey() + 1000,
]);
expect(fn () => app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalTypes: ['securityDefaults'],
))->toThrow(InvalidArgumentException::class, 'target scope');
expect($graph->calls)->toBe([])
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec424 renders Security Defaults inspect summaries and compare details from DB only', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'scopes_granted' => ['Policy.Read.All'],
]);
app()->instance(GraphClientInterface::class, spec424CaptureGraphClient([
spec424CaptureSecurityDefaultsPayload(['isEnabled' => false]),
spec424CaptureSecurityDefaultsPayload(['isEnabled' => true, 'clientSecret' => 'spec424-render-secret']),
]));
app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: spec424CaptureRun($user, $environment, $connection),
canonicalTypes: ['securityDefaults'],
);
app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: spec424CaptureRun($user, $environment, $connection),
canonicalTypes: ['securityDefaults'],
);
$resource = TenantConfigurationResource::query()->sole();
app()->instance(GraphClientInterface::class, spec424FailingGraphClient());
$details = assertNoOutboundHttp(fn (): array => app(CoverageV2ReadinessReadModel::class)
->inspectDetails($resource, $environment, $user));
$summary = $details['typed_render_summary'] ?? null;
$encodedSummary = json_encode($summary, JSON_THROW_ON_ERROR);
expect($summary)->toBeArray()
->and($summary['resource_type'])->toBe('Security Defaults')
->and($summary['state'])->toBe('Enabled')
->and($summary['compare_summary']['status'])->toBe('Material changes detected')
->and($summary['compare_summary']['changed'])->toBeTrue()
->and(collect($summary['compare_summary']['changes'])->pluck('label')->all())->toContain('Enabled State')
->and($encodedSummary)->not->toContain('spec424-render-secret')
->and($encodedSummary)->not->toContain('source_endpoint')
->and($encodedSummary)->not->toContain('identitySecurityDefaultsEnforcementPolicy');
});
it('Spec424 denies Security Defaults inspect and page access outside the actor scope', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
[, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
app()->instance(GraphClientInterface::class, spec424CaptureGraphClient([
spec424CaptureSecurityDefaultsPayload(),
]));
app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: spec424CaptureRun($user, $environment, $connection),
canonicalTypes: ['securityDefaults'],
);
$resource = TenantConfigurationResource::query()->sole();
$outsider = User::factory()->create();
$outsiderDetails = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $outsider);
expect(app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $foreignEnvironment, $user))->toBe([])
->and($outsiderDetails['operation_run_url'] ?? null)->toBeNull();
$this->actingAs($outsider)
->get(CoverageV2Readiness::getUrl(tenant: $environment))
->assertNotFound();
});
it('Spec424 returns forbidden for Security Defaults readiness when view capability is denied', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
spec424CoverageActingAs($user, $environment);
app()->instance(ManagedEnvironmentAccessScopeResolver::class, new class
{
public function decision(User $user, $environment, ?string $requiredCapability = null): ManagedEnvironmentAccessDecision
{
return new ManagedEnvironmentAccessDecision(
workspaceId: (int) $environment->workspace_id,
managedEnvironmentId: (int) $environment->getKey(),
userId: (int) $user->getKey(),
workspaceMember: true,
workspaceRole: 'owner',
explicitScopeRowsPresent: false,
managedEnvironmentAllowed: true,
failedBoundary: 'capability',
requiredCapability: $requiredCapability,
capabilityAllowed: false,
denialHttpStatus: 403,
);
}
});
try {
$this->get(CoverageV2Readiness::getUrl(tenant: $environment))
->assertForbidden();
} finally {
app()->forgetInstance(ManagedEnvironmentAccessScopeResolver::class);
}
});
it('Spec424 readonly users cannot start Security Defaults capture', function (): void {
Queue::fake();
[$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
expect(fn () => app(StartTenantConfigurationCapture::class)->start($environment, $connection, $user, [
'securityDefaults',
]))->toThrow(AuthorizationException::class);
Queue::assertNothingPushed();
});
it('Spec424 adds no tenant id ownership, mini-platform, route, Filament resource, or customer output file', function (): void {
$newPlatformFiles = collect([
'apps/platform/app/Filament',
'apps/platform/app/Models',
'apps/platform/routes',
'apps/platform/database/migrations',
])
->flatMap(fn (string $path): array => glob(repo_path($path).'/**/*424*') ?: [])
->map(fn (string $path): string => str_replace(repo_path().DIRECTORY_SEPARATOR, '', $path))
->values()
->all();
$runtimeFiles = [
app_path('Services/TenantConfiguration/ClaimGuard.php'),
app_path('Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php'),
app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'),
app_path('Services/TenantConfiguration/EntraComparablePayloadNormalizer.php'),
app_path('Services/TenantConfiguration/EntraCoverageComparator.php'),
app_path('Services/TenantConfiguration/EntraRenderableSummaryBuilder.php'),
app_path('Services/TenantConfiguration/ResourceTypeRegistry.php'),
app_path('Services/Graph/MicrosoftGraphClient.php'),
config_path('graph_contracts.php'),
];
$joinedRuntime = implode("\n", array_map(static fn (string $path): string => file_get_contents($path) ?: '', $runtimeFiles));
expect($newPlatformFiles)->toBe([])
->and($joinedRuntime)->not->toContain('tenant_id')
->and($joinedRuntime)->not->toContain('ReviewPack')
->and($joinedRuntime)->not->toContain('customer-ready')
->and($joinedRuntime)->not->toContain('certification-ready')
->and($joinedRuntime)->not->toContain('restore-ready');
});
function spec424CaptureRun(
$user,
$environment,
ProviderConnection $connection,
array $resourceTypes = ['securityDefaults'],
array $targetScopeOverrides = [],
): OperationRun {
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'target_scope' => array_replace([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
], $targetScopeOverrides),
'resource_types' => $resourceTypes,
'required_capability' => 'evidence.manage',
],
]);
}
function spec424CaptureGraphClient(array $responses): GraphClientInterface
{
return new class($responses) implements GraphClientInterface
{
public array $calls = [];
public function __construct(private array $responses) {}
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->calls[] = ['policy_type' => $policyType, 'options' => $options];
$response = array_shift($this->responses);
if ($response instanceof GraphResponse) {
return $response;
}
return new GraphResponse(true, is_array($response) ? $response : []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
};
}
function spec424CoverageActingAs(User $user, $environment): void
{
test()->actingAs($user);
$environment->makeCurrent();
Filament::setTenant($environment, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
}
function spec424CaptureSecurityDefaultsPayload(array $overrides = []): array
{
return array_replace([
'id' => 'securityDefaults',
'displayName' => 'Security Defaults',
'description' => 'Tenant-wide Security Defaults policy.',
'isEnabled' => true,
], $overrides);
}
function spec424FailingGraphClient(): GraphClientInterface
{
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
public function getOrganization(array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
throw new RuntimeException('Spec424 render path must not call provider clients.');
}
};
}