214 lines
8.8 KiB
PHP
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);
|
|
}
|