Implemented the first version of review output resolve actions. Included a ReviewOutputResolveActionMapper, commands to seed browser fixtures, updated CustomerReviewWorkspace, EnvironmentReviewResource, UI enforcement, and related views. Also added extensive unit, feature, and browser tests, and updated the design coverage matrix. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #422
543 lines
23 KiB
PHP
543 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Filament\Resources\EnvironmentReviewResource;
|
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EnvironmentReviewSection;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ManagedEnvironmentMembership;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\User;
|
|
use App\Models\UserTenantPreference;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
|
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
|
use App\Services\Evidence\EvidenceSnapshotService;
|
|
use App\Support\EnvironmentReviewCompletenessState;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SeedReviewOutputBrowserFixture extends Command
|
|
{
|
|
protected $signature = 'tenantpilot:review-output:seed-browser-fixture';
|
|
|
|
protected $description = 'Seed a local/testing browser fixture for the Spec 351 ready-review publish scenario.';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! app()->environment(['local', 'testing'])) {
|
|
$this->error('This fixture command is limited to local and testing environments.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$fixture = config('tenantpilot.review_output.browser_smoke_fixture');
|
|
|
|
if (! is_array($fixture)) {
|
|
$this->error('The review-output browser smoke fixture is not configured.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
|
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
|
$scenarioConfig = is_array($fixture['ready_draft'] ?? null) ? $fixture['ready_draft'] : [];
|
|
|
|
$workspace = Workspace::query()->updateOrCreate(
|
|
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-351-review-output-smoke')],
|
|
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 351 Review Output Smoke')],
|
|
);
|
|
|
|
$password = (string) ($userConfig['password'] ?? 'password');
|
|
|
|
$user = User::query()->updateOrCreate(
|
|
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+351@tenantpilot.local')],
|
|
[
|
|
'name' => (string) ($userConfig['name'] ?? 'Spec 351 Requester'),
|
|
'password' => Hash::make($password),
|
|
'email_verified_at' => now(),
|
|
],
|
|
);
|
|
|
|
$environment = ManagedEnvironment::query()->updateOrCreate(
|
|
['slug' => (string) ($scenarioConfig['managed_environment_slug'] ?? 'spec-351-browser-ready-draft')],
|
|
[
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'name' => (string) ($scenarioConfig['managed_environment_name'] ?? 'Spec 351 Browser Ready Draft'),
|
|
'lifecycle_status' => ManagedEnvironment::STATUS_ACTIVE,
|
|
'kind' => 'dev',
|
|
'is_current' => false,
|
|
'metadata' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
|
'rbac_status' => 'ok',
|
|
'rbac_last_checked_at' => now(),
|
|
],
|
|
);
|
|
|
|
WorkspaceMembership::query()->updateOrCreate(
|
|
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
|
['role' => 'owner'],
|
|
);
|
|
|
|
ManagedEnvironmentMembership::query()->updateOrCreate(
|
|
['managed_environment_id' => (int) $environment->getKey(), 'user_id' => (int) $user->getKey()],
|
|
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-351-review-output-browser-smoke'],
|
|
);
|
|
|
|
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
}
|
|
|
|
if (Schema::hasTable('user_managed_environment_preferences')) {
|
|
UserTenantPreference::query()->updateOrCreate(
|
|
['user_id' => (int) $user->getKey(), 'managed_environment_id' => (int) $environment->getKey()],
|
|
['last_used_at' => now()],
|
|
);
|
|
}
|
|
|
|
$readyReview = DB::transaction(function () use ($environment, $user, $scenarioConfig): EnvironmentReview {
|
|
$this->resetFixtureGraph($environment);
|
|
|
|
$snapshot = $this->seedReadyEvidenceSnapshot($environment, $user, $scenarioConfig);
|
|
|
|
return $this->seedPublishedLoopWithReadySuccessor($environment, $user, $snapshot, $scenarioConfig);
|
|
});
|
|
|
|
$workspaceUrl = CustomerReviewWorkspace::environmentFilterUrl($environment);
|
|
$detailUrl = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $readyReview], $environment);
|
|
$detailRedirect = $this->relativeAdminRedirect($detailUrl);
|
|
$publishedReviewId = EnvironmentReview::query()
|
|
->where('superseded_by_review_id', (int) $readyReview->getKey())
|
|
->latest('id')
|
|
->value('id');
|
|
$loginUrl = route('admin.local.smoke-login', [
|
|
'email' => (string) $user->email,
|
|
'tenant' => (string) $environment->slug,
|
|
'workspace' => (string) $workspace->slug,
|
|
'redirect' => $detailRedirect,
|
|
], absolute: false);
|
|
|
|
$this->table(
|
|
['Fixture', 'Value'],
|
|
[
|
|
['Workspace', (string) $workspace->name],
|
|
['User email', (string) $user->email],
|
|
['User password', $password],
|
|
['ManagedEnvironment', (string) $environment->name],
|
|
['Workspace URL', $workspaceUrl],
|
|
['Published review id', $publishedReviewId !== null ? (string) $publishedReviewId : '—'],
|
|
['Ready review id', (string) $readyReview->getKey()],
|
|
['Ready review detail URL', $detailUrl],
|
|
['Fixture login URL', $loginUrl],
|
|
],
|
|
);
|
|
|
|
$this->info('The fixture seeds a published review plus a linked ready successor so the workspace shows Open draft review and the detail page shows Publish review.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function resetFixtureGraph(ManagedEnvironment $environment): void
|
|
{
|
|
$environmentId = (int) $environment->getKey();
|
|
$reviewIds = EnvironmentReview::query()
|
|
->where('managed_environment_id', $environmentId)
|
|
->pluck('id');
|
|
$snapshotIds = EvidenceSnapshot::query()
|
|
->where('managed_environment_id', $environmentId)
|
|
->pluck('id');
|
|
|
|
if ($reviewIds->isNotEmpty()) {
|
|
ReviewPack::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
|
EnvironmentReviewSection::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
|
EnvironmentReview::query()->whereIn('id', $reviewIds)->delete();
|
|
}
|
|
|
|
if ($snapshotIds->isNotEmpty()) {
|
|
EvidenceSnapshotItem::query()->whereIn('evidence_snapshot_id', $snapshotIds)->delete();
|
|
EvidenceSnapshot::query()->whereIn('id', $snapshotIds)->delete();
|
|
}
|
|
|
|
StoredReport::query()->where('managed_environment_id', $environmentId)->delete();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function seedReadyEvidenceSnapshot(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
array $scenarioConfig,
|
|
): EvidenceSnapshot {
|
|
StoredReport::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
'payload' => [
|
|
'posture_score' => 92,
|
|
'required_count' => 14,
|
|
'granted_count' => 14,
|
|
'checked_at' => now()->subMinutes(5)->toIso8601String(),
|
|
'permissions' => [
|
|
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
StoredReport::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
'payload' => [
|
|
'provider_key' => 'microsoft',
|
|
'domain' => 'entra.admin_roles',
|
|
'measured_at' => now()->subMinutes(5)->toIso8601String(),
|
|
'roles' => [
|
|
[
|
|
'displayName' => 'Global Administrator',
|
|
'userPrincipalName' => 'admin@contoso.com',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$operationRun = OperationRun::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'initiator_name' => (string) ($scenarioConfig['operation_initiator_name'] ?? 'Spec 351 Browser Operator'),
|
|
'type' => OperationRunType::PolicySync->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'run_identity_hash' => hash('sha256', sprintf(
|
|
'spec-351-review-output-browser-smoke-%s-%s',
|
|
$environment->getKey(),
|
|
(string) Str::uuid(),
|
|
)),
|
|
'summary_counts' => [],
|
|
'failure_summary' => [],
|
|
'context' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
|
'started_at' => now()->subMinutes(6),
|
|
'completed_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
/** @var EvidenceSnapshotService $service */
|
|
$service = app(EvidenceSnapshotService::class);
|
|
$payload = $service->buildSnapshotPayload($environment);
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'operation_run_id' => (int) $operationRun->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'fingerprint' => $payload['fingerprint'],
|
|
'completeness_state' => $payload['completeness'],
|
|
'summary' => $payload['summary'],
|
|
'generated_at' => now()->subMinutes(4),
|
|
]);
|
|
|
|
foreach ($payload['items'] as $item) {
|
|
$snapshot->items()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'dimension_key' => $item['dimension_key'],
|
|
'state' => $item['state'],
|
|
'required' => $item['required'],
|
|
'source_kind' => $item['source_kind'],
|
|
'source_record_type' => $item['source_record_type'],
|
|
'source_record_id' => $item['source_record_id'],
|
|
'source_fingerprint' => $item['source_fingerprint'],
|
|
'measured_at' => $item['measured_at'],
|
|
'freshness_at' => $item['freshness_at'],
|
|
'summary_payload' => $item['summary_payload'],
|
|
'sort_order' => $item['sort_order'],
|
|
]);
|
|
}
|
|
|
|
return $snapshot->load('items');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function seedPublishedLoopWithReadySuccessor(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
EvidenceSnapshot $snapshot,
|
|
array $scenarioConfig,
|
|
): EnvironmentReview {
|
|
/** @var EnvironmentReviewService $service */
|
|
$service = app(EnvironmentReviewService::class);
|
|
/** @var EnvironmentReviewLifecycleService $lifecycle */
|
|
$lifecycle = app(EnvironmentReviewLifecycleService::class);
|
|
|
|
Queue::fake();
|
|
|
|
$publishedReview = $service->create($environment, $snapshot, $user)->refresh();
|
|
|
|
if ($publishedReview->generated_at === null || ! $publishedReview->sections()->exists()) {
|
|
$publishedReview = $service->compose($publishedReview);
|
|
}
|
|
|
|
$publishedReview = $this->markReadyReview($publishedReview);
|
|
$publishedReview->forceFill([
|
|
'status' => EnvironmentReviewStatus::Ready->value,
|
|
'published_at' => null,
|
|
'published_by_user_id' => null,
|
|
])->save();
|
|
|
|
$publishedReview = $lifecycle->publish(
|
|
$publishedReview->refresh(),
|
|
$user,
|
|
(string) ($scenarioConfig['publish_reason'] ?? 'Seed published predecessor for Spec 351 browser verification.'),
|
|
);
|
|
|
|
$this->attachPublishedReviewPack($environment, $user, $publishedReview, $snapshot, $scenarioConfig);
|
|
|
|
$successorReview = $lifecycle->createNextReview($publishedReview->fresh(), $user, $snapshot)->refresh();
|
|
|
|
if ($successorReview->generated_at === null || ! $successorReview->sections()->exists()) {
|
|
$successorReview = $service->compose($successorReview);
|
|
}
|
|
|
|
$successorReview = $this->markReadyReview($successorReview);
|
|
$successorReview->forceFill([
|
|
'status' => EnvironmentReviewStatus::Ready->value,
|
|
'published_at' => null,
|
|
'published_by_user_id' => null,
|
|
])->save();
|
|
|
|
return $successorReview->refresh()->load(['tenant', 'evidenceSnapshot.items', 'sections']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $scenarioConfig
|
|
*/
|
|
private function attachPublishedReviewPack(
|
|
ManagedEnvironment $environment,
|
|
User $user,
|
|
EnvironmentReview $review,
|
|
EvidenceSnapshot $snapshot,
|
|
array $scenarioConfig,
|
|
): void {
|
|
$filePath = (string) ($scenarioConfig['published_review_pack_path'] ?? 'review-packs/spec351-browser-ready-published.zip');
|
|
$fileContents = (string) ($scenarioConfig['published_review_pack_contents'] ?? 'PK-spec351-browser-ready-published');
|
|
|
|
Storage::disk('exports')->put($filePath, $fileContents);
|
|
|
|
$fingerprint = hash('sha256', implode('|', [
|
|
'spec-351-review-output-browser-smoke-pack',
|
|
(string) $environment->getKey(),
|
|
(string) $review->getKey(),
|
|
$filePath,
|
|
]));
|
|
|
|
$reviewPack = ReviewPack::query()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'initiated_by_user_id' => (int) $user->getKey(),
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'environment_review_id' => (int) $review->getKey(),
|
|
'status' => ReviewPack::STATUS_READY,
|
|
'fingerprint' => $fingerprint,
|
|
'summary' => [
|
|
'fixture' => 'spec-351-review-output-browser-smoke',
|
|
],
|
|
'options' => [
|
|
'include_pii' => false,
|
|
'include_operations' => true,
|
|
],
|
|
'file_disk' => 'exports',
|
|
'file_path' => $filePath,
|
|
'file_size' => strlen($fileContents),
|
|
'sha256' => hash('sha256', $fileContents),
|
|
'generated_at' => now()->subMinutes(3),
|
|
'expires_at' => now()->addDays(7),
|
|
]);
|
|
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$summary['has_ready_export'] = true;
|
|
|
|
$review->forceFill([
|
|
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
'summary' => $summary,
|
|
])->save();
|
|
}
|
|
|
|
private function markReadyReview(EnvironmentReview $review): EnvironmentReview
|
|
{
|
|
$review->loadMissing(['sections', 'evidenceSnapshot.items']);
|
|
|
|
$disclosure = 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.';
|
|
$controlSummary = [
|
|
'control_key' => 'customer-output',
|
|
'control_name' => 'Customer output',
|
|
'domain_key' => 'customer_delivery',
|
|
'readiness_bucket' => 'evidence_on_record',
|
|
'readiness_label' => 'Evidence on record',
|
|
'limitation_flags' => [],
|
|
'limitation_labels' => [],
|
|
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_summary' => '1 evidence signal references this control.',
|
|
'accepted_risk_summary' => null,
|
|
'recommended_next_action' => 'Publish review.',
|
|
'detail_anchor' => 'control-customer-output',
|
|
'supporting_finding_ids' => [],
|
|
'finding_count' => 0,
|
|
'open_finding_count' => 0,
|
|
'accepted_risk_count' => 0,
|
|
];
|
|
$controlExplanation = [
|
|
'title' => 'Customer output',
|
|
'control_key' => 'customer-output',
|
|
'control_name' => 'Customer output',
|
|
'readiness_bucket' => 'evidence_on_record',
|
|
'readiness_label' => 'Evidence on record',
|
|
'limitation_flags' => [],
|
|
'limitation_labels' => [],
|
|
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_summary' => '1 evidence signal references this control.',
|
|
'accepted_risk_summary' => null,
|
|
'explanation_text' => 'Customer output has evidence on record in this released review.',
|
|
'evidence_basis_items' => [
|
|
'1 evidence signal references this control.',
|
|
],
|
|
'accepted_risk_context' => null,
|
|
'recommended_next_action' => 'Publish review.',
|
|
'proof_access_state' => 'available',
|
|
'supporting_finding_ids' => [],
|
|
];
|
|
|
|
$snapshot = $review->evidenceSnapshot;
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot) {
|
|
$snapshot->items->each(function (EvidenceSnapshotItem $item): void {
|
|
$item->forceFill([
|
|
'state' => EvidenceCompletenessState::Complete->value,
|
|
])->save();
|
|
});
|
|
|
|
$snapshotSummary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
|
$snapshotSummary['missing_dimensions'] = 0;
|
|
$snapshotSummary['stale_dimensions'] = 0;
|
|
$snapshotSummary['dimensions'] = collect($snapshotSummary['dimensions'] ?? [])
|
|
->map(static function (mixed $dimension): mixed {
|
|
if (! is_array($dimension)) {
|
|
return $dimension;
|
|
}
|
|
|
|
$dimension['state'] = EnvironmentReviewCompletenessState::Complete->value;
|
|
|
|
return $dimension;
|
|
})
|
|
->values()
|
|
->all();
|
|
|
|
$snapshot->forceFill([
|
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
'summary' => $snapshotSummary,
|
|
])->save();
|
|
}
|
|
|
|
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void {
|
|
$attributes = [
|
|
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
|
];
|
|
|
|
if ($section->isControlInterpretation()) {
|
|
$attributes['summary_payload'] = array_replace_recursive([
|
|
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
|
'display_label' => 'Compliance evidence mapping v1',
|
|
'non_certification_disclosure' => $disclosure,
|
|
'mapped_control_count' => 1,
|
|
'follow_up_required_count' => 0,
|
|
'limitation_counts' => [],
|
|
'limitations' => [],
|
|
], is_array($section->summary_payload) ? $section->summary_payload : []);
|
|
$attributes['render_payload'] = array_replace_recursive([
|
|
'entries' => [$controlExplanation],
|
|
'disclosure' => $disclosure,
|
|
'next_actions' => ['Publish review.'],
|
|
'empty_state' => null,
|
|
], is_array($section->render_payload) ? $section->render_payload : []);
|
|
}
|
|
|
|
$section->forceFill($attributes)->save();
|
|
});
|
|
|
|
$sectionCount = $review->sections->count();
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$existingControlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
|
? $summary['control_interpretation']
|
|
: [];
|
|
$existingControls = collect($existingControlInterpretation['controls'] ?? [])
|
|
->filter(static fn (mixed $control): bool => is_array($control))
|
|
->values();
|
|
|
|
$summary['control_interpretation'] = array_replace_recursive([
|
|
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
|
'display_label' => 'Compliance evidence mapping v1',
|
|
'non_certification_disclosure' => $disclosure,
|
|
'mapped_control_count' => 1,
|
|
'follow_up_required_count' => 0,
|
|
'limitation_counts' => [],
|
|
'limitations' => [],
|
|
'controls' => [$controlSummary],
|
|
], $existingControlInterpretation);
|
|
|
|
$summary['control_interpretation']['controls'] = [
|
|
array_replace(
|
|
$controlSummary,
|
|
is_array($existingControls->first()) ? $existingControls->first() : [],
|
|
),
|
|
];
|
|
$summary['publish_blockers'] = [];
|
|
$summary['section_count'] = $sectionCount;
|
|
$summary['section_state_counts'] = [
|
|
'complete' => $sectionCount,
|
|
'partial' => 0,
|
|
'missing' => 0,
|
|
'stale' => 0,
|
|
];
|
|
|
|
$review->forceFill([
|
|
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
|
'summary' => $summary,
|
|
])->save();
|
|
|
|
return $review->refresh();
|
|
}
|
|
|
|
private function relativeAdminRedirect(string $url): string
|
|
{
|
|
$parsed = parse_url($url);
|
|
|
|
$path = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
|
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
|
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
|
|
|
return $path.$query.$fragment;
|
|
}
|
|
}
|