TenantAtlas/apps/platform/tests/Unit/Support/TenantConfiguration/Spec424SecurityDefaultsSourceContractTest.php
Ahmed Darrazi 6fbb5c97ad
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m4s
chore: complete spec 424 implementation
2026-07-01 16:40:08 +02:00

214 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\TenantConfiguration\CanonicalIdentityResolver;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\IdentityState;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('Spec424 resolves Security Defaults only through the explicit graph v1 contract', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec424SecurityDefaultsUnitResourceType());
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->contractKey)->toBe('securityDefaults')
->and($decision->sourceEndpoint)->toBe('/policies/identitySecurityDefaultsEnforcementPolicy')
->and($decision->sourceVersion)->toBe('v1.0')
->and($decision->sourceSchemaHash)->toBeString()->not->toBe('')
->and($decision->sourceMetadata['source_contract_key'])->toBe('securityDefaults')
->and($decision->sourceMetadata['registry_source_class'])->toBe('graph_v1_fallback')
->and($decision->sourceMetadata['registry_support_state'])->toBe('fallback_supported');
});
it('Spec424 blocks Security Defaults capture when the graph contract resource is missing', function (): void {
config()->set('graph_contracts.types.securityDefaults', []);
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec424SecurityDefaultsUnitResourceType());
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe('missing_graph_contract_resource')
->and($decision->contractKey)->toBeNull()
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->sourceMetadata['reason_code'])->toBe('missing_graph_contract_resource');
});
it('Spec424 declares a bounded Security Defaults graph contract', function (): void {
$contract = config('graph_contracts.types.securityDefaults');
expect($contract['resource'])->toBe('policies/identitySecurityDefaultsEnforcementPolicy')
->and($contract['graph_version'])->toBe('v1.0')
->and($contract['response_shape'])->toBe('singleton')
->and($contract['allowed_select'])->toBe(['id', 'displayName', 'description', 'isEnabled'])
->and($contract['allowed_expand'])->toBe([])
->and($contract['volatile_fields'])->toBe(['@odata.context', '@odata.etag'])
->and($contract['read_permissions'])->toBe(['Policy.Read.All'])
->and($contract)->not->toHaveKey('create_method')
->and($contract)->not->toHaveKey('update_method');
});
it('Spec424 list policies calls the Security Defaults v1.0 endpoint without using beta', function (): void {
config()->set('graph.base_url', 'https://graph.microsoft.com');
config()->set('graph.version', 'beta');
Http::fake([
'https://graph.microsoft.com/*' => Http::response([
'id' => 'securityDefaults',
'displayName' => 'Security Defaults',
'description' => 'Tenant-wide defaults',
'isEnabled' => true,
], 200),
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull();
$logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull();
$response = (new MicrosoftGraphClient(
logger: $logger,
contracts: app(GraphContractRegistry::class),
))->listPolicies('securityDefaults', [
'access_token' => 'spec424-test-token',
'top' => 999,
]);
expect($response->successful())->toBeTrue()
->and($response->data['id'])->toBe('securityDefaults');
Http::assertSent(function (Request $request): bool {
$url = $request->url();
if (! str_contains($url, '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy')) {
return false;
}
if (str_contains($url, '/beta/')) {
return false;
}
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
expect($query['$select'] ?? null)->toBe('id,displayName,description,isEnabled');
expect($query)->not->toHaveKey('$top');
return true;
});
});
it('Spec424 live graph contract check probes the Security Defaults singleton without top', function (): void {
$client = new class implements GraphClientInterface
{
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
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
{
$this->requests[] = [
'method' => $method,
'path' => $path,
'options' => $options,
];
return new GraphResponse(true, []);
}
};
app()->instance(GraphClientInterface::class, $client);
$this->artisan('graph:contract:check')->assertSuccessful();
$request = collect($client->requests)
->firstWhere('path', 'policies/identitySecurityDefaultsEnforcementPolicy');
expect($request)->not->toBeNull()
->and($request['options']['query'])->toHaveKey('$select', 'id,displayName,description,isEnabled')
->and($request['options']['query'])->not->toHaveKey('$top')
->and($request['options']['graph_version'] ?? null)->toBe('v1.0');
});
it('Spec424 resolves Security Defaults identity only from a stable Graph id', function (): void {
$resourceType = spec424SecurityDefaultsUnitResourceType();
$stable = app(CanonicalIdentityResolver::class)->resolve($resourceType, [
'id' => 'securityDefaults',
'displayName' => 'Security Defaults',
'isEnabled' => true,
], [
'source_contract_key' => 'securityDefaults',
'source_version' => 'v1.0',
]);
$missing = app(CanonicalIdentityResolver::class)->resolve($resourceType, [
'displayName' => 'Security Defaults',
'isEnabled' => true,
], [
'source_contract_key' => 'securityDefaults',
'source_version' => 'v1.0',
]);
$sourceIdOnly = app(CanonicalIdentityResolver::class)->resolve($resourceType, [
'sourceId' => 'securityDefaults-provider-alias',
'displayName' => 'Security Defaults',
'isEnabled' => true,
], [
'source_contract_key' => 'securityDefaults',
'source_version' => 'v1.0',
]);
expect($stable->identityState)->toBe(IdentityState::Stable)
->and($stable->keyKind)->toBe(CanonicalKeyKind::GraphObjectId)
->and($stable->sourceResourceId)->toBe('securityDefaults')
->and($stable->strategyIdentifier)->toBe('graph.security_defaults.v1')
->and($missing->identityState)->toBe(IdentityState::MissingExternalId)
->and($missing->keyKind)->toBe(CanonicalKeyKind::Unsupported)
->and($missing->diagnostics['reason_code'])->toBe('missing_external_id')
->and($missing->canonicalResourceKey)->not->toContain('displayName')
->and($sourceIdOnly->identityState)->toBe(IdentityState::MissingExternalId)
->and($sourceIdOnly->keyKind)->toBe(CanonicalKeyKind::Unsupported)
->and($sourceIdOnly->diagnostics['reason_code'])->toBe('missing_external_id')
->and($sourceIdOnly->sourceResourceId)->toStartWith('missing:');
});
function spec424SecurityDefaultsUnitResourceType(): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', 'securityDefaults');
expect($definition)->not->toBeNull('Missing default resource type definition for securityDefaults.');
return new TenantConfigurationResourceType($definition);
}