455 lines
21 KiB
PHP
455 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphResponse;
|
|
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
|
|
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
|
|
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
|
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\SourceClass;
|
|
|
|
it('Spec423 exposes typed Security and Compliance summaries with readiness and compare details without provider calls', function (string $canonicalType, array $previousPayload, array $latestPayload, string $resourceType, string $expectedText, string $expectedChange): void {
|
|
[$user, $environment, $resource] = spec423FeatureEvidencePair($canonicalType, $previousPayload, $latestPayload);
|
|
app()->instance(GraphClientInterface::class, spec423FailingGraphClient());
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
|
|
$summary = $details['typed_render_summary'] ?? null;
|
|
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
|
|
|
expect($summary)->toBeArray()
|
|
->and($summary['resource_type'])->toBe($resourceType)
|
|
->and($encoded)->toContain($expectedText)
|
|
->and($encoded)->toContain('readiness_requires_manual_review')
|
|
->and($encoded)->not->toContain('raw_payload')
|
|
->and($encoded)->not->toContain('source_endpoint')
|
|
->and($encoded)->not->toContain('spec423-feature-secret')
|
|
->and($encoded)->not->toContain('spec423-feature-content')
|
|
->and($summary['compare_summary']['status'])->toBe('Manual review required')
|
|
->and($summary['compare_summary']['changed'])->toBeTrue()
|
|
->and(collect($summary['compare_summary']['changes'])->pluck('label'))->toContain($expectedChange);
|
|
})->with([
|
|
'retention policy' => [
|
|
'retentionCompliancePolicy',
|
|
[
|
|
'DisplayName' => 'Spec423 Feature Retention Policy',
|
|
'RetentionDuration' => 5,
|
|
'RetentionDurationUnit' => 'Years',
|
|
'DispositionAction' => 'Keep',
|
|
'IncludedLocations' => ['Exchange'],
|
|
],
|
|
[
|
|
'DisplayName' => 'Spec423 Feature Retention Policy',
|
|
'RetentionDuration' => 7,
|
|
'RetentionDurationUnit' => 'Years',
|
|
'DispositionAction' => 'Delete',
|
|
'IncludedLocations' => ['Exchange'],
|
|
'clientSecret' => 'spec423-feature-secret',
|
|
],
|
|
'Retention compliance policy',
|
|
'7 Years',
|
|
'Retention Duration',
|
|
],
|
|
'label policy' => [
|
|
'labelPolicy',
|
|
[
|
|
'DisplayName' => 'Spec423 Feature Label Policy',
|
|
'PublishedLabels' => [['displayName' => 'General']],
|
|
'Mandatory' => false,
|
|
],
|
|
[
|
|
'DisplayName' => 'Spec423 Feature Label Policy',
|
|
'PublishedLabels' => [['displayName' => 'Highly Confidential']],
|
|
'Mandatory' => true,
|
|
],
|
|
'Label policy',
|
|
'Highly Confidential',
|
|
'Labeling Published Labels',
|
|
],
|
|
'dlp policy' => [
|
|
'dlpCompliancePolicy',
|
|
[
|
|
'DisplayName' => 'Spec423 Feature DLP Policy',
|
|
'Mode' => 'Audit',
|
|
'Locations' => ['Exchange'],
|
|
'Rules' => [['Name' => 'Rule', 'Actions' => ['NotifyUser']]],
|
|
],
|
|
[
|
|
'DisplayName' => 'Spec423 Feature DLP Policy',
|
|
'Mode' => 'Enforce',
|
|
'Locations' => ['Exchange'],
|
|
'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess'], 'DlpIncidentContent' => 'spec423-feature-content']],
|
|
],
|
|
'DLP compliance policy',
|
|
'Enforce',
|
|
'Mode',
|
|
],
|
|
]);
|
|
|
|
it('Spec423 does not render typed summaries for non-renderable latest evidence', function (): void {
|
|
[$user, $environment, $resource, $latestEvidence] = spec423FeatureEvidencePair(
|
|
'retentionCompliancePolicy',
|
|
['DisplayName' => 'Spec423 Non Renderable Retention', 'RetentionDuration' => 5],
|
|
['DisplayName' => 'Spec423 Non Renderable Retention', 'RetentionDuration' => 7],
|
|
);
|
|
$latestEvidence->forceFill(['coverage_level' => CoverageLevel::ContentBacked->value])->save();
|
|
$resource->unsetRelation('latestEvidence');
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
|
|
|
|
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
|
});
|
|
|
|
it('Spec423 requires latest evidence to belong to the same provider connection as the resource', function (): void {
|
|
[$user, $environment, $resource, $latestEvidence] = spec423FeatureEvidencePair(
|
|
'labelPolicy',
|
|
['DisplayName' => 'Spec423 Provider Labels', 'Mandatory' => false],
|
|
['DisplayName' => 'Spec423 Provider Labels', 'Mandatory' => true],
|
|
);
|
|
$foreignConnection = ProviderConnection::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
]);
|
|
$latestEvidence->forceFill(['provider_connection_id' => (int) $foreignConnection->getKey()])->save();
|
|
$resource->unsetRelation('latestEvidence');
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
|
|
|
|
expect($details['typed_render_summary'] ?? null)->toBeNull();
|
|
});
|
|
|
|
it('Spec423 promotes mandatory Security and Compliance content-backed evidence rows to renderable coverage', function (string $canonicalType, array $payload): 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(),
|
|
]);
|
|
$resourceType = spec423FeatureResourceType($canonicalType);
|
|
$resource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'canonical_type' => $canonicalType,
|
|
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-promotion',
|
|
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
|
'source_resource_id' => 'spec423-promotion',
|
|
'source_display_name' => 'Spec423 promotion '.$canonicalType,
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
|
]);
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
]);
|
|
|
|
$evidence = app(CoverageEvidenceWriter::class)->append(
|
|
resource: $resource,
|
|
resourceType: $resourceType,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
decision: new CoverageSourceContractDecision(
|
|
canonicalType: $canonicalType,
|
|
outcome: CaptureOutcome::Captured,
|
|
contractKey: 'spec423.synthetic.'.$canonicalType,
|
|
sourceEndpoint: '/spec423/synthetic/'.$canonicalType,
|
|
),
|
|
rawPayload: $payload,
|
|
normalizedPayload: $payload,
|
|
payloadHash: hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
|
|
);
|
|
|
|
expect($evidence)->toBeInstanceOf(TenantConfigurationResourceEvidence::class)
|
|
->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
|
|
->and($resource->fresh()->latest_evidence_state)->toBe(EvidenceState::ContentBacked);
|
|
})->with([
|
|
'retentionCompliancePolicy' => ['retentionCompliancePolicy', ['DisplayName' => 'Retention', 'RetentionDuration' => 7, 'DispositionAction' => 'Delete', 'clientSecret' => 'spec423-promotion-secret']],
|
|
'labelPolicy' => ['labelPolicy', ['DisplayName' => 'Labels', 'PublishedLabels' => [['displayName' => 'Highly Confidential']]]],
|
|
'dlpCompliancePolicy' => ['dlpCompliancePolicy', ['DisplayName' => 'DLP', 'Mode' => 'Enforce', 'Rules' => [['Name' => 'Rule', 'Actions' => ['BlockAccess']]]]],
|
|
]);
|
|
|
|
it('Spec423 keeps unknown nested material fields renderable with manual-review readiness', 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(),
|
|
]);
|
|
$canonicalType = 'dlpCompliancePolicy';
|
|
$resourceType = spec423FeatureResourceType($canonicalType);
|
|
$payload = [
|
|
'DisplayName' => 'Spec423 Nested DLP',
|
|
'Mode' => 'Enforce',
|
|
'Rules' => [[
|
|
'Name' => 'Nested condition rule',
|
|
'Actions' => ['BlockAccess'],
|
|
'Conditions' => ['SensitiveInfoTypes' => ['Spec423 Credit Card Detector']],
|
|
]],
|
|
];
|
|
$resource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'canonical_type' => $canonicalType,
|
|
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-nested',
|
|
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
|
'source_resource_id' => 'spec423-nested',
|
|
'source_display_name' => 'Spec423 Nested DLP',
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
|
]);
|
|
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
]);
|
|
|
|
$evidence = app(CoverageEvidenceWriter::class)->append(
|
|
resource: $resource,
|
|
resourceType: $resourceType,
|
|
providerConnection: $connection,
|
|
operationRun: $run,
|
|
decision: new CoverageSourceContractDecision(
|
|
canonicalType: $canonicalType,
|
|
outcome: CaptureOutcome::Captured,
|
|
contractKey: 'spec423.synthetic.'.$canonicalType,
|
|
sourceEndpoint: '/spec423/synthetic/'.$canonicalType,
|
|
),
|
|
rawPayload: $payload,
|
|
normalizedPayload: $payload,
|
|
payloadHash: hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
|
|
);
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->refresh(), $environment, $user);
|
|
$summary = $details['typed_render_summary'] ?? null;
|
|
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
|
|
|
|
expect($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
|
|
->and($summary)->toBeArray()
|
|
->and(data_get($summary, 'readiness.state'))->toBe('readiness_requires_manual_review')
|
|
->and(data_get($summary, 'readiness.label'))->toBe('Manual review required')
|
|
->and($summary['unsupported_fields'])->toContain('Rules.0.Conditions')
|
|
->and($encoded)->not->toContain('Spec423 Credit Card Detector');
|
|
});
|
|
|
|
it('Spec423 leaves optional Security and Compliance types unpromoted without bounded evidence', function (): void {
|
|
app(ResourceTypeRegistry::class)->syncDefaults();
|
|
|
|
$resourceTypes = TenantConfigurationResourceType::query()
|
|
->whereIn('canonical_type', [
|
|
'autoSensitivityLabelPolicy',
|
|
'protectionAlert',
|
|
'complianceTag',
|
|
])
|
|
->get()
|
|
->keyBy('canonical_type');
|
|
|
|
expect($resourceTypes)->toHaveCount(3);
|
|
|
|
foreach ($resourceTypes as $resourceType) {
|
|
expect($resourceType->default_coverage_level)->toBe(CoverageLevel::Detected)
|
|
->and($resourceType->default_evidence_state)->toBe(EvidenceState::NotCaptured)
|
|
->and($resourceType->allows_certified_claims)->toBeFalse()
|
|
->and($resourceType->restore_tier->value)->toBe('not_restorable');
|
|
}
|
|
});
|
|
|
|
it('Spec423 keeps Security and Compliance support separate from restore, legal, certification, customer output, and tenant ownership', function (): void {
|
|
$paths = [
|
|
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceComparablePayloadNormalizer.php',
|
|
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceCoverageComparator.php',
|
|
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceRenderableSummaryBuilder.php',
|
|
'apps/platform/app/Services/TenantConfiguration/SecurityComplianceReadinessEvaluator.php',
|
|
'apps/platform/app/Services/TenantConfiguration/ClaimGuard.php',
|
|
];
|
|
$content = collect($paths)
|
|
->map(fn (string $path): string => file_exists(repo_path($path)) ? (file_get_contents(repo_path($path)) ?: '') : '')
|
|
->implode("\n");
|
|
|
|
expect($content)
|
|
->not->toContain('GraphClientInterface')
|
|
->not->toContain('Http::')
|
|
->not->toContain('tenant_id')
|
|
->not->toContain('restore-ready')
|
|
->not->toContain('certification-ready')
|
|
->not->toContain('legal-ready')
|
|
->not->toContain('customer-ready')
|
|
->not->toContain('ReviewPack')
|
|
->not->toContain('namespace App\\Services\\TenantConfiguration\\SecurityCompliance');
|
|
});
|
|
|
|
/**
|
|
* @return array{0: mixed, 1: mixed, 2: TenantConfigurationResource, 3: TenantConfigurationResourceEvidence}
|
|
*/
|
|
function spec423FeatureEvidencePair(string $canonicalType, array $previousPayload, array $latestPayload): array
|
|
{
|
|
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(),
|
|
]);
|
|
$resourceType = spec423FeatureResourceType($canonicalType);
|
|
$displayName = (string) ($latestPayload['DisplayName'] ?? $latestPayload['displayName'] ?? 'Spec423 Feature Resource');
|
|
$resource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'canonical_type' => $canonicalType,
|
|
'canonical_resource_key' => $canonicalType.':provider_external_id:spec423-feature',
|
|
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
|
|
'source_resource_id' => 'spec423-feature',
|
|
'source_display_name' => $displayName,
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'source_metadata' => [
|
|
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
|
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
|
'source_version' => 'v1.0',
|
|
'registry_source_class' => SourceClass::Tcm->value,
|
|
'registry_support_state' => 'out_of_scope',
|
|
],
|
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::InternalOnly->value,
|
|
'latest_captured_at' => now(),
|
|
]);
|
|
$previousRun = spec423FeatureRun($user, $environment, $connection, $canonicalType, minutesAgo: 5);
|
|
$latestRun = spec423FeatureRun($user, $environment, $connection, $canonicalType);
|
|
|
|
TenantConfigurationResourceEvidence::factory()->create([
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'operation_run_id' => (int) $previousRun->getKey(),
|
|
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
|
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
|
'source_version' => 'v1.0',
|
|
'raw_payload' => ['id' => 'spec423-feature'],
|
|
'normalized_payload' => $previousPayload,
|
|
'payload_hash' => hash('sha256', json_encode($previousPayload, JSON_THROW_ON_ERROR)),
|
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
|
'coverage_level' => CoverageLevel::Comparable->value,
|
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
|
'captured_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
$latestEvidence = TenantConfigurationResourceEvidence::factory()->create([
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resourceType->getKey(),
|
|
'operation_run_id' => (int) $latestRun->getKey(),
|
|
'source_contract_key' => 'spec423.synthetic.'.$canonicalType,
|
|
'source_endpoint' => '/spec423/synthetic/'.$canonicalType,
|
|
'source_version' => 'v1.0',
|
|
'raw_payload' => ['id' => 'spec423-feature', 'secret' => 'spec423-feature-secret'],
|
|
'normalized_payload' => $latestPayload,
|
|
'payload_hash' => hash('sha256', json_encode($latestPayload, JSON_THROW_ON_ERROR)),
|
|
'evidence_state' => EvidenceState::ContentBacked->value,
|
|
'coverage_level' => CoverageLevel::Renderable->value,
|
|
'capture_outcome' => CaptureOutcome::Captured->value,
|
|
'captured_at' => now(),
|
|
]);
|
|
|
|
$resource->forceFill([
|
|
'latest_evidence_id' => (int) $latestEvidence->getKey(),
|
|
'latest_payload_hash' => (string) $latestEvidence->payload_hash,
|
|
])->save();
|
|
|
|
return [$user, $environment, $resource->refresh(), $latestEvidence];
|
|
}
|
|
|
|
function spec423FeatureResourceType(string $canonicalType): TenantConfigurationResourceType
|
|
{
|
|
return TenantConfigurationResourceType::query()
|
|
->where('canonical_type', $canonicalType)
|
|
->where('source_class', SourceClass::Tcm->value)
|
|
->firstOrFail();
|
|
}
|
|
|
|
function spec423FeatureRun($user, $environment, ProviderConnection $connection, string $canonicalType, int $minutesAgo = 0): OperationRun
|
|
{
|
|
$timestamp = $minutesAgo > 0 ? now()->subMinutes($minutesAgo) : now();
|
|
|
|
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'context' => [
|
|
'target_scope' => [
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
],
|
|
'resource_types' => [$canonicalType],
|
|
],
|
|
'started_at' => $timestamp,
|
|
'completed_at' => $timestamp,
|
|
]);
|
|
}
|
|
|
|
function spec423FailingGraphClient(): GraphClientInterface
|
|
{
|
|
return new class implements GraphClientInterface
|
|
{
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
throw new RuntimeException('Spec423 render path must not call provider clients.');
|
|
}
|
|
};
|
|
}
|