merge: session work
This commit is contained in:
commit
8eb575ea41
@ -5,6 +5,8 @@
|
||||
namespace App\Filament\Pages\Workspaces;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -14,6 +16,7 @@
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
@ -21,6 +24,7 @@
|
||||
use App\Services\Providers\CredentialManager;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
@ -28,6 +32,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
@ -51,6 +56,7 @@
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
@ -286,7 +292,7 @@ public function content(Schema $schema): Schema
|
||||
SchemaActions::make([
|
||||
Action::make('wizardStartVerification')
|
||||
->label('Start verification')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress')
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||
? null
|
||||
@ -294,7 +300,7 @@ public function content(Schema $schema): Schema
|
||||
->action(fn () => $this->startVerification()),
|
||||
Action::make('wizardRefreshVerification')
|
||||
->label('Refresh')
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null)
|
||||
->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress')
|
||||
->action(fn () => $this->refreshVerificationStatus()),
|
||||
]),
|
||||
ViewField::make('verification_report')
|
||||
@ -629,7 +635,22 @@ private function verificationRunUrl(): ?string
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{run: array<string, mixed>|null, runUrl: string|null}
|
||||
* @return array{
|
||||
* run: array<string, mixed>|null,
|
||||
* runUrl: string|null,
|
||||
* report: array<string, mixed>|null,
|
||||
* fingerprint: string|null,
|
||||
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
|
||||
* previousRunUrl: string|null,
|
||||
* canAcknowledge: bool,
|
||||
* acknowledgements: array<string, array{
|
||||
* check_key: string,
|
||||
* ack_reason: string,
|
||||
* acknowledged_at: string|null,
|
||||
* expires_at: string|null,
|
||||
* acknowledged_by: array{id: int, name: string}|null
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private function verificationReportViewData(): array
|
||||
{
|
||||
@ -640,9 +661,54 @@ private function verificationReportViewData(): array
|
||||
return [
|
||||
'run' => null,
|
||||
'runUrl' => $runUrl,
|
||||
'report' => null,
|
||||
'fingerprint' => null,
|
||||
'changeIndicator' => null,
|
||||
'previousRunUrl' => null,
|
||||
'canAcknowledge' => false,
|
||||
'acknowledgements' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
||||
$previousRunUrl = $changeIndicator === null
|
||||
? null
|
||||
: $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']);
|
||||
|
||||
$user = auth()->user();
|
||||
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
|
||||
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
|
||||
: false;
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$targetScope = $context['target_scope'] ?? [];
|
||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
||||
@ -650,19 +716,162 @@ private function verificationReportViewData(): array
|
||||
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => (string) $run->initiator_name,
|
||||
'started_at' => $run->started_at?->toJSON(),
|
||||
'completed_at' => $run->completed_at?->toJSON(),
|
||||
'target_scope' => $targetScope,
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
];
|
||||
'run' => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => (string) $run->initiator_name,
|
||||
'started_at' => $run->started_at?->toJSON(),
|
||||
'completed_at' => $run->completed_at?->toJSON(),
|
||||
'target_scope' => $targetScope,
|
||||
'failures' => $failures,
|
||||
],
|
||||
'runUrl' => $runUrl,
|
||||
'report' => $report,
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'canAcknowledge' => $canAcknowledge,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
}
|
||||
|
||||
public function acknowledgeVerificationCheckAction(): Action
|
||||
{
|
||||
return Action::make('acknowledgeVerificationCheck')
|
||||
->label('Acknowledge')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Acknowledge issue')
|
||||
->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.')
|
||||
->form([
|
||||
Textarea::make('ack_reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->maxLength(160)
|
||||
->rows(3),
|
||||
TextInput::make('expires_at')
|
||||
->label('Expiry (optional)')
|
||||
->helperText('Optional timestamp (informational only).')
|
||||
->maxLength(64),
|
||||
])
|
||||
->action(function (array $data, array $arguments): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$checkKey = (string) ($arguments['check_key'] ?? '');
|
||||
$ackReason = (string) ($data['ack_reason'] ?? '');
|
||||
$expiresAt = $data['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) ? $expiresAt : null;
|
||||
|
||||
try {
|
||||
app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
||||
tenant: $tenant,
|
||||
run: $run,
|
||||
checkKey: $checkKey,
|
||||
ackReason: $ackReason,
|
||||
expiresAt: $expiresAt,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
Notification::make()
|
||||
->title('Unable to acknowledge')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Issue acknowledged')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(function (array $arguments): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$run = $this->verificationRun();
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checkKey = trim((string) ($arguments['check_key'] ?? ''));
|
||||
|
||||
if ($checkKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ackExists = VerificationCheckAcknowledgement::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->where('check_key', $checkKey)
|
||||
->exists();
|
||||
|
||||
if ($ackExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$report = VerificationReportViewer::report($run);
|
||||
|
||||
if (! is_array($report)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($check['key'] ?? null) !== $checkKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
return is_string($status) && in_array($status, [
|
||||
VerificationCheckStatus::Fail->value,
|
||||
VerificationCheckStatus::Warn->value,
|
||||
], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private function bootstrapRunsLabel(): string
|
||||
|
||||
@ -3,12 +3,16 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages;
|
||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||
use App\Filament\Support\VerificationReportViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
@ -143,6 +147,63 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.components.verification-report-viewer')
|
||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||
->viewData(function (OperationRun $record): array {
|
||||
$report = VerificationReportViewer::report($record);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
||||
|
||||
$previousRunUrl = null;
|
||||
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||
}
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||
->where('operation_run_id', (int) $record->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $record->getKey(),
|
||||
'type' => (string) $record->type,
|
||||
'status' => (string) $record->status,
|
||||
'outcome' => (string) $record->outcome,
|
||||
'started_at' => $record->started_at?->toJSON(),
|
||||
'completed_at' => $record->completed_at?->toJSON(),
|
||||
],
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||
|
||||
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
47
app/Filament/Support/VerificationReportChangeIndicator.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
|
||||
final class VerificationReportChangeIndicator
|
||||
{
|
||||
/**
|
||||
* @return array{state: 'no_changes'|'changed', previous_report_id: int}|null
|
||||
*/
|
||||
public static function forRun(OperationRun $run): ?array
|
||||
{
|
||||
$report = VerificationReportViewer::report($run);
|
||||
|
||||
if ($report === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previousRun = VerificationReportViewer::previousRun($run, $report);
|
||||
|
||||
if ($previousRun === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previousReport = VerificationReportViewer::report($previousRun);
|
||||
|
||||
if ($previousReport === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentFingerprint = VerificationReportViewer::fingerprint($report);
|
||||
$previousFingerprint = VerificationReportViewer::fingerprint($previousReport);
|
||||
|
||||
if ($currentFingerprint === null || $previousFingerprint === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => $currentFingerprint === $previousFingerprint ? 'no_changes' : 'changed',
|
||||
'previous_report_id' => (int) $previousRun->getKey(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
|
||||
@ -31,6 +32,53 @@ public static function report(OperationRun $run): ?array
|
||||
return $report;
|
||||
}
|
||||
|
||||
public static function previousReportId(array $report): ?int
|
||||
{
|
||||
$previousReportId = $report['previous_report_id'] ?? null;
|
||||
|
||||
if (is_int($previousReportId) && $previousReportId > 0) {
|
||||
return $previousReportId;
|
||||
}
|
||||
|
||||
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
|
||||
return (int) trim($previousReportId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function fingerprint(array $report): ?string
|
||||
{
|
||||
$fingerprint = $report['fingerprint'] ?? null;
|
||||
|
||||
if (is_string($fingerprint)) {
|
||||
$fingerprint = strtolower(trim($fingerprint));
|
||||
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
return $fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
return VerificationReportFingerprint::forReport($report);
|
||||
}
|
||||
|
||||
public static function previousRun(OperationRun $run, array $report): ?OperationRun
|
||||
{
|
||||
$previousReportId = self::previousReportId($report);
|
||||
|
||||
if ($previousReportId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previous = OperationRun::query()
|
||||
->whereKey($previousReportId)
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->first();
|
||||
|
||||
return $previous instanceof OperationRun ? $previous : null;
|
||||
}
|
||||
|
||||
public static function shouldRenderForRun(OperationRun $run): bool
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
41
app/Models/VerificationCheckAcknowledgement.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VerificationCheckAcknowledgement extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\VerificationCheckAcknowledgementFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'acknowledged_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||
@ -44,6 +45,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class VerificationCheckAcknowledgementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkspaceAuditLogger $audit,
|
||||
) {}
|
||||
|
||||
public function acknowledge(
|
||||
Tenant $tenant,
|
||||
OperationRun $run,
|
||||
string $checkKey,
|
||||
string $ackReason,
|
||||
?string $expiresAt,
|
||||
User $actor,
|
||||
): VerificationCheckAcknowledgement {
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant);
|
||||
|
||||
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$checkKey = trim($checkKey);
|
||||
if ($checkKey === '') {
|
||||
throw new InvalidArgumentException('check_key is required.');
|
||||
}
|
||||
|
||||
$ackReason = trim($ackReason);
|
||||
if ($ackReason === '') {
|
||||
throw new InvalidArgumentException('ack_reason is required.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ackReason) > 160) {
|
||||
throw new InvalidArgumentException('ack_reason must be at most 160 characters.');
|
||||
}
|
||||
|
||||
$report = $this->reportForRun($run);
|
||||
$check = $this->findCheckByKey($report, $checkKey);
|
||||
|
||||
$status = $check['status'] ?? null;
|
||||
|
||||
if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) {
|
||||
throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.');
|
||||
}
|
||||
|
||||
$reasonCode = $check['reason_code'] ?? null;
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
throw new InvalidArgumentException('Check reason_code is required.');
|
||||
}
|
||||
|
||||
$expiresAtParsed = null;
|
||||
|
||||
if ($expiresAt !== null && trim($expiresAt) !== '') {
|
||||
try {
|
||||
$expiresAtParsed = CarbonImmutable::parse($expiresAt);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException('expires_at must be a valid date-time.');
|
||||
}
|
||||
|
||||
if ($expiresAtParsed->isBefore(CarbonImmutable::now())) {
|
||||
throw new InvalidArgumentException('expires_at must be in the future.');
|
||||
}
|
||||
}
|
||||
|
||||
$acknowledgedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$ack = VerificationCheckAcknowledgement::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'check_key' => $checkKey,
|
||||
'ack_reason' => $ackReason,
|
||||
'expires_at' => $expiresAtParsed,
|
||||
'acknowledged_at' => $acknowledgedAt,
|
||||
'acknowledged_by_user_id' => (int) $actor->getKey(),
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
$ack = VerificationCheckAcknowledgement::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->where('check_key', $checkKey)
|
||||
->first();
|
||||
|
||||
if (! $ack instanceof VerificationCheckAcknowledgement) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
if ($ack->wasRecentlyCreated) {
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if ($workspace !== null) {
|
||||
$this->audit->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::VerificationCheckAcknowledged->value,
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'report_id' => (int) $run->getKey(),
|
||||
'flow' => (string) $run->type,
|
||||
'check_key' => $checkKey,
|
||||
'reason_code' => $reasonCode,
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $ack;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reportForRun(OperationRun $run): array
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$report = $context['verification_report'] ?? null;
|
||||
|
||||
if (! is_array($report)) {
|
||||
throw new InvalidArgumentException('Verification report is missing.');
|
||||
}
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
throw new InvalidArgumentException('Verification report is invalid.');
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findCheckByKey(array $report, string $checkKey): array
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($check['key'] ?? null) === $checkKey) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Check not found in verification report.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,4 +29,5 @@ enum AuditActionId: string
|
||||
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
|
||||
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ private static function sanitizeString(string $value): string
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
|
||||
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
|
||||
@ -61,6 +61,9 @@ class Capabilities
|
||||
// Findings
|
||||
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||
|
||||
// Verification
|
||||
public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge';
|
||||
|
||||
// Tenant memberships
|
||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class PreviousVerificationReportResolver
|
||||
{
|
||||
public static function resolvePreviousReportId(OperationRun $run): ?int
|
||||
{
|
||||
$runId = $run->getKey();
|
||||
|
||||
if (! is_int($runId) || $runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = self::providerConnectionId($run);
|
||||
|
||||
$query = OperationRun::query()
|
||||
->where('tenant_id', (int) $run->tenant_id)
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where('type', (string) $run->type)
|
||||
->where('run_identity_hash', (string) $run->run_identity_hash)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('id', '<', $runId)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($providerConnectionId !== null) {
|
||||
$query->where('context->provider_connection_id', $providerConnectionId);
|
||||
} else {
|
||||
$query->whereNull('context->provider_connection_id');
|
||||
}
|
||||
|
||||
$previousId = $query->value('id');
|
||||
|
||||
return is_int($previousId) ? $previousId : null;
|
||||
}
|
||||
|
||||
private static function providerConnectionId(OperationRun $run): ?int
|
||||
{
|
||||
$context = $run->context;
|
||||
|
||||
if (! is_array($context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||
|
||||
if (is_int($providerConnectionId)) {
|
||||
return $providerConnectionId;
|
||||
}
|
||||
|
||||
if (is_string($providerConnectionId) && ctype_digit(trim($providerConnectionId))) {
|
||||
return (int) trim($providerConnectionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
96
app/Support/Verification/VerificationReportFingerprint.php
Normal file
96
app/Support/Verification/VerificationReportFingerprint.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Verification;
|
||||
|
||||
final class VerificationReportFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $checks
|
||||
*/
|
||||
public static function forChecks(array $checks): string
|
||||
{
|
||||
$tuples = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if (! is_array($check)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = self::normalizeKey($check['key'] ?? null);
|
||||
$status = self::normalizeEnumString($check['status'] ?? null);
|
||||
$reasonCode = self::normalizeEnumString($check['reason_code'] ?? null);
|
||||
|
||||
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
$severity = is_string($severity) ? trim($severity) : '';
|
||||
|
||||
if ($severity === '') {
|
||||
$severity = '';
|
||||
} else {
|
||||
$severity = strtolower($severity);
|
||||
}
|
||||
|
||||
$tuples[] = [
|
||||
'key' => $key,
|
||||
'tuple' => implode('|', [
|
||||
$key,
|
||||
$status,
|
||||
$blocking ? '1' : '0',
|
||||
$reasonCode,
|
||||
$severity,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
usort($tuples, static function (array $a, array $b): int {
|
||||
$keyComparison = $a['key'] <=> $b['key'];
|
||||
|
||||
if ($keyComparison !== 0) {
|
||||
return $keyComparison;
|
||||
}
|
||||
|
||||
return $a['tuple'] <=> $b['tuple'];
|
||||
});
|
||||
|
||||
$payload = implode("\n", array_map(static fn (array $item): string => (string) $item['tuple'], $tuples));
|
||||
|
||||
return hash('sha256', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
public static function forReport(array $report): string
|
||||
{
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> $checks */
|
||||
return self::forChecks($checks);
|
||||
}
|
||||
|
||||
private static function normalizeKey(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : $value;
|
||||
}
|
||||
|
||||
private static function normalizeEnumString(mixed $value): string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? '' : strtolower($value);
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,40 @@ public static function sanitizeReport(array $report): array
|
||||
$sanitized['generated_at'] = $generatedAt;
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (is_string($fingerprint)) {
|
||||
$fingerprint = strtolower(trim($fingerprint));
|
||||
|
||||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
$sanitized['fingerprint'] = $fingerprint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId === null || is_int($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
} elseif (is_string($previousReportId)) {
|
||||
$previousReportId = trim($previousReportId);
|
||||
|
||||
if ($previousReportId === '') {
|
||||
$sanitized['previous_report_id'] = null;
|
||||
} elseif (ctype_digit($previousReportId)) {
|
||||
$sanitized['previous_report_id'] = (int) $previousReportId;
|
||||
} else {
|
||||
$previousReportId = self::sanitizeShortString($previousReportId, fallback: null);
|
||||
|
||||
if ($previousReportId !== null) {
|
||||
$sanitized['previous_report_id'] = $previousReportId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($report['identity'] ?? null)) {
|
||||
$identity = self::sanitizeIdentity((array) $report['identity']);
|
||||
|
||||
@ -164,8 +198,14 @@ private static function sanitizeChecks(array $checks): ?array
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
$severityRaw = $check['severity'] ?? null;
|
||||
if (! is_string($severityRaw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severityRaw));
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
final class VerificationReportSchema
|
||||
{
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
|
||||
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
@ -78,6 +78,22 @@ public static function isValidReport(array $report): bool
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('fingerprint', $report)) {
|
||||
$fingerprint = $report['fingerprint'];
|
||||
|
||||
if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('previous_report_id', $report)) {
|
||||
$previousReportId = $report['previous_report_id'];
|
||||
|
||||
if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -137,7 +153,13 @@ private static function isValidCheckResult(array $check): bool
|
||||
}
|
||||
|
||||
$severity = $check['severity'] ?? null;
|
||||
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
if (! is_string($severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$severity = trim($severity);
|
||||
|
||||
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,8 @@ public static function write(OperationRun $run, array $checks, array $identity =
|
||||
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
||||
|
||||
$report = self::build($flow, $checks, $identity);
|
||||
$report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run);
|
||||
|
||||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||||
|
||||
if (! VerificationReportSchema::isValidReport($report)) {
|
||||
@ -75,6 +77,8 @@ public static function build(string $flow, array $checks, array $identity = []):
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow,
|
||||
'generated_at' => now()->toISOString(),
|
||||
'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks),
|
||||
'previous_report_id' => null,
|
||||
'summary' => [
|
||||
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
||||
'counts' => $counts,
|
||||
@ -98,6 +102,8 @@ private static function buildFallbackReport(string $flow): array
|
||||
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
||||
'flow' => $flow !== '' ? $flow : 'unknown',
|
||||
'generated_at' => now()->toISOString(),
|
||||
'fingerprint' => VerificationReportFingerprint::forChecks([]),
|
||||
'previous_report_id' => null,
|
||||
'summary' => [
|
||||
'overall' => VerificationReportOverall::NeedsAttention->value,
|
||||
'counts' => [
|
||||
@ -161,14 +167,12 @@ private static function normalizeCheckStatus(mixed $status): string
|
||||
private static function normalizeCheckSeverity(mixed $severity): string
|
||||
{
|
||||
if (! is_string($severity)) {
|
||||
return VerificationCheckSeverity::Info->value;
|
||||
return '';
|
||||
}
|
||||
|
||||
$severity = strtolower(trim($severity));
|
||||
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true)
|
||||
? $severity
|
||||
: VerificationCheckSeverity::Info->value;
|
||||
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
|
||||
}
|
||||
|
||||
private static function normalizeReasonCode(mixed $reasonCode): string
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<VerificationCheckAcknowledgement>
|
||||
*/
|
||||
class VerificationCheckAcknowledgementFactory extends Factory
|
||||
{
|
||||
protected $model = VerificationCheckAcknowledgement::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'operation_run_id' => function (): int {
|
||||
return (int) OperationRun::factory()->create()->getKey();
|
||||
},
|
||||
'tenant_id' => function (array $attributes): int {
|
||||
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('tenant_id');
|
||||
},
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('workspace_id');
|
||||
},
|
||||
'check_key' => 'provider_connection.token_acquisition',
|
||||
'ack_reason' => fake()->sentence(6),
|
||||
'expires_at' => null,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('verification_check_acknowledgements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->constrained('operation_runs')->cascadeOnDelete();
|
||||
|
||||
$table->string('check_key');
|
||||
$table->string('ack_reason', 160);
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestampTz('acknowledged_at');
|
||||
$table->foreignId('acknowledged_by_user_id')->constrained('users');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['operation_run_id', 'check_key']);
|
||||
|
||||
$table->index(['tenant_id', 'workspace_id', 'operation_run_id']);
|
||||
$table->index(['operation_run_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('verification_check_acknowledgements');
|
||||
}
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
|
||||
@ -2,14 +2,95 @@
|
||||
$report = isset($getState) ? $getState() : ($report ?? null);
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$run = $run ?? null;
|
||||
$run = is_array($run) ? $run : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = $summary['counts'] ?? null;
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -21,6 +102,9 @@
|
||||
<div class="mt-1">
|
||||
This run doesn’t have a report yet. If it’s still running, refresh in a moment. If it already completed, start verification again.
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
@ -30,149 +114,377 @@
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($checks === [])
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
No checks found in this report. Start verification again to generate a fresh report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($checks as $check)
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? $message : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$evidence = $check['evidence'] ?? [];
|
||||
$evidence = is_array($evidence) ? $evidence : [];
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="flex cursor-pointer items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ tab: 'issues' }" class="space-y-4">
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@if ($evidence !== [] || $nextSteps !== [])
|
||||
<div class="mt-4 space-y-4">
|
||||
@if ($evidence !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Evidence
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($evidence as $pointer)
|
||||
@php
|
||||
$pointer = is_array($pointer) ? $pointer : [];
|
||||
$kind = $pointer['kind'] ?? null;
|
||||
$value = $pointer['value'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
|
||||
<li>
|
||||
<span class="font-medium">{{ $kind }}:</span>
|
||||
<span>{{ is_int($value) ? $value : $value }}</span>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</details>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@if ($run !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,23 @@
|
||||
$runUrl = $runUrl ?? null;
|
||||
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
|
||||
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$fingerprint = $fingerprint ?? null;
|
||||
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
|
||||
|
||||
$changeIndicator = $changeIndicator ?? null;
|
||||
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
|
||||
|
||||
$previousRunUrl = $previousRunUrl ?? null;
|
||||
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
|
||||
|
||||
$canAcknowledge = (bool) ($canAcknowledge ?? false);
|
||||
|
||||
$acknowledgements = $acknowledgements ?? [];
|
||||
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
|
||||
|
||||
$status = $run['status'] ?? null;
|
||||
$status = is_string($status) ? $status : null;
|
||||
|
||||
@ -31,6 +48,87 @@
|
||||
$completedAtLabel = $completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $report['summary'] ?? null;
|
||||
$summary = is_array($summary) ? $summary : null;
|
||||
|
||||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||||
|
||||
$checks = $report['checks'] ?? null;
|
||||
$checks = is_array($checks) ? $checks : [];
|
||||
|
||||
$ackByKey = [];
|
||||
|
||||
foreach ($acknowledgements as $checkKey => $ack) {
|
||||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ackByKey[$checkKey] = $ack;
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
$failures = [];
|
||||
$warnings = [];
|
||||
$acknowledgedIssues = [];
|
||||
$passed = [];
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$key = $check['key'] ?? null;
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusValue = $check['status'] ?? null;
|
||||
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
|
||||
if (array_key_exists($key, $ackByKey)) {
|
||||
$acknowledgedIssues[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'pass') {
|
||||
$passed[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail' && $blocking) {
|
||||
$blockers[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'fail') {
|
||||
$failures[] = $check;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($statusValue === 'warn') {
|
||||
$warnings[] = $check;
|
||||
}
|
||||
}
|
||||
|
||||
$sortChecks = static function (array $a, array $b): int {
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
};
|
||||
|
||||
usort($blockers, $sortChecks);
|
||||
usort($failures, $sortChecks);
|
||||
usort($warnings, $sortChecks);
|
||||
usort($acknowledgedIssues, $sortChecks);
|
||||
usort($passed, $sortChecks);
|
||||
|
||||
$ackAction = null;
|
||||
|
||||
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
|
||||
$ackAction = $this->acknowledgeVerificationCheckAction();
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@ -47,88 +145,450 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
||||
</div>
|
||||
@elseif ($outcome === 'succeeded')
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
All verification checks passed.
|
||||
</div>
|
||||
@elseif ($failures === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Report unavailable. The run completed, but no failure details were recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Findings
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($failures as $failure)
|
||||
@php
|
||||
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
|
||||
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
|
||||
|
||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
||||
$message = is_string($message) && $message !== '' ? $message : null;
|
||||
@endphp
|
||||
|
||||
@if ($reasonCode !== null || $message !== null)
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
@if ($reasonCode !== null)
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $reasonCode }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($message !== null)
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($targetScope !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target scope
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
@php
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
||||
$overallSpec = $summary === null
|
||||
? null
|
||||
: \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
|
||||
$summary['overall'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
@if ($entraTenantName !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($overallSpec)
|
||||
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
|
||||
{{ $overallSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($entraTenantId !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['total'] ?? 0) }} total
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($counts['pass'] ?? 0) }} pass
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($counts['fail'] ?? 0) }} fail
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($counts['warn'] ?? 0) }} warn
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
{{ (int) ($counts['skip'] ?? 0) }} skip
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ (int) ($counts['running'] ?? 0) }} running
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($changeIndicator !== null)
|
||||
@php
|
||||
$state = $changeIndicator['state'] ?? null;
|
||||
$state = is_string($state) ? $state : null;
|
||||
@endphp
|
||||
|
||||
@if ($state === 'no_changes')
|
||||
<x-filament::badge color="success">
|
||||
No changes since previous verification
|
||||
</x-filament::badge>
|
||||
@elseif ($state === 'changed')
|
||||
<x-filament::badge color="warning">
|
||||
Changed since previous verification
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($runUrl !== null)
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="{{ $runUrl }}"
|
||||
class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open run details
|
||||
</a>
|
||||
@if ($report === null || $summary === null)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
Verification report unavailable
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
This run doesn’t have a report yet. If it already completed, start verification again.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="{ tab: 'issues' }"
|
||||
class="space-y-4"
|
||||
>
|
||||
<x-filament::tabs label="Verification report tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
alpine-active="tab === 'issues'"
|
||||
x-on:click="tab = 'issues'"
|
||||
>
|
||||
Issues
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'passed'"
|
||||
x-on:click="tab = 'passed'"
|
||||
>
|
||||
Passed
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="false"
|
||||
alpine-active="tab === 'technical'"
|
||||
x-on:click="tab = 'technical'"
|
||||
>
|
||||
Technical details
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div x-show="tab === 'issues'">
|
||||
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
No issues found in this report.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php
|
||||
$issueGroups = [
|
||||
['label' => 'Blockers', 'checks' => $blockers],
|
||||
['label' => 'Failures', 'checks' => $failures],
|
||||
['label' => 'Warnings', 'checks' => $warnings],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($issueGroups as $group)
|
||||
@php
|
||||
$label = $group['label'];
|
||||
$groupChecks = $group['checks'];
|
||||
@endphp
|
||||
|
||||
@if ($groupChecks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $label }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($groupChecks as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$nextSteps = $check['next_steps'] ?? [];
|
||||
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
|
||||
|
||||
$blocking = $check['blocking'] ?? false;
|
||||
$blocking = is_bool($blocking) ? $blocking : false;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
@if ($blocking)
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Blocker
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
|
||||
{{ ($ackAction)(['check_key' => $checkKey]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="mt-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next steps
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@foreach ($nextSteps as $step)
|
||||
@php
|
||||
$step = is_array($step) ? $step : [];
|
||||
$label = $step['label'] ?? null;
|
||||
$url = $step['url'] ?? null;
|
||||
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
|
||||
@endphp
|
||||
|
||||
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
|
||||
<li>
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
@if ($isExternal)
|
||||
target="_blank" rel="noreferrer"
|
||||
@endif
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($acknowledgedIssues !== [])
|
||||
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Acknowledged issues
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@foreach ($acknowledgedIssues as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$message = $check['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
|
||||
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
|
||||
$check['severity'] ?? null,
|
||||
);
|
||||
|
||||
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
|
||||
$ack = is_array($ack) ? $ack : null;
|
||||
|
||||
$ackReason = $ack['ack_reason'] ?? null;
|
||||
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
|
||||
|
||||
$ackAt = $ack['acknowledged_at'] ?? null;
|
||||
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
|
||||
|
||||
$ackBy = $ack['acknowledged_by'] ?? null;
|
||||
$ackBy = is_array($ackBy) ? $ackBy : null;
|
||||
|
||||
$ackByName = $ackBy['name'] ?? null;
|
||||
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
|
||||
|
||||
$expiresAt = $ack['expires_at'] ?? null;
|
||||
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($message)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
|
||||
{{ $severitySpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
|
||||
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@if ($ackReason)
|
||||
<div>
|
||||
<span class="font-semibold">Reason:</span> {{ $ackReason }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($ackByName || $ackAt)
|
||||
<div>
|
||||
<span class="font-semibold">Acknowledged:</span>
|
||||
@if ($ackByName)
|
||||
{{ $ackByName }}
|
||||
@endif
|
||||
@if ($ackAt)
|
||||
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($expiresAt)
|
||||
<div>
|
||||
<span class="font-semibold">Expires:</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'passed'" style="display: none;">
|
||||
@if ($passed === [])
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No passing checks recorded.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($passed as $check)
|
||||
@php
|
||||
$check = is_array($check) ? $check : [];
|
||||
|
||||
$title = $check['title'] ?? 'Check';
|
||||
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
|
||||
$check['status'] ?? null,
|
||||
);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'technical'" style="display: none;">
|
||||
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Identifiers
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
||||
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
|
||||
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
|
||||
</div>
|
||||
@if ($fingerprint)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
|
||||
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($previousRunUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $previousRunUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open previous verification
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($runUrl !== null)
|
||||
<div>
|
||||
<a
|
||||
href="{{ $runUrl }}"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>
|
||||
Open run details
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($targetScope !== [])
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target scope
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
@php
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||
|
||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
||||
@endphp
|
||||
|
||||
@if ($entraTenantName !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($entraTenantId !== null)
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
||||
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
34
specs/075-verification-v1-5/checklists/requirements.md
Normal file
34
specs/075-verification-v1-5/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Verification Checklist Framework V1.5
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-05
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against template used in [specs/074-verification-checklist/spec.md](../../074-verification-checklist/spec.md). No open clarifications remain.
|
||||
@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/acknowledge-check.request.schema.json",
|
||||
"title": "AcknowledgeVerificationCheckRequest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"report_id",
|
||||
"check_key",
|
||||
"ack_reason"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"check_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ack_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"expires_at": {
|
||||
"description": "Optional informational expiry timestamp.",
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/verification-check-acknowledgement.schema.json",
|
||||
"title": "VerificationCheckAcknowledgement",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"report_id",
|
||||
"check_key",
|
||||
"ack_reason",
|
||||
"acknowledged_at",
|
||||
"acknowledged_by"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"description": "OperationRun id that contains the report.",
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"check_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ack_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"expires_at": {
|
||||
"description": "Informational only in v1.5.",
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
},
|
||||
"acknowledged_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"acknowledged_by": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": ["string", "integer"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"email": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/verification-report.v1_5.schema.json",
|
||||
"title": "VerificationReportV1_5",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"flow",
|
||||
"generated_at",
|
||||
"fingerprint",
|
||||
"previous_report_id",
|
||||
"summary",
|
||||
"checks"
|
||||
],
|
||||
"properties": {
|
||||
"report_id": {
|
||||
"description": "Canonical report identifier. In v1.5 this is the OperationRun id.",
|
||||
"type": ["string", "integer"]
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"description": "Version of the verification report schema (SemVer, major 1).",
|
||||
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"flow": {
|
||||
"type": "string",
|
||||
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
|
||||
},
|
||||
"previous_report_id": {
|
||||
"description": "Previous report id for the same identity (nullable).",
|
||||
"type": ["string", "integer", "null"]
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"description": "Scope identifiers for what is being verified.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"description": "Deterministic SHA-256 hash (lowercase hex) of normalized check outcomes.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "counts"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["ready", "needs_attention", "blocked", "running"],
|
||||
"description": "Overall state derived from check results."
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["total", "pass", "fail", "warn", "skip", "running"],
|
||||
"properties": {
|
||||
"total": { "type": "integer", "minimum": 0 },
|
||||
"pass": { "type": "integer", "minimum": 0 },
|
||||
"fail": { "type": "integer", "minimum": 0 },
|
||||
"warn": { "type": "integer", "minimum": 0 },
|
||||
"skip": { "type": "integer", "minimum": 0 },
|
||||
"running": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"$ref": "#/$defs/CheckResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"CheckResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"title",
|
||||
"status",
|
||||
"severity",
|
||||
"blocking",
|
||||
"reason_code",
|
||||
"message",
|
||||
"evidence",
|
||||
"next_steps"
|
||||
],
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pass", "fail", "warn", "skip", "running"]
|
||||
},
|
||||
"severity": {
|
||||
"description": "Must be included for fingerprint determinism; may be empty string.",
|
||||
"type": "string",
|
||||
"enum": ["", "info", "low", "medium", "high", "critical"]
|
||||
},
|
||||
"blocking": { "type": "boolean" },
|
||||
"reason_code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/EvidencePointer" }
|
||||
},
|
||||
"next_steps": {
|
||||
"type": "array",
|
||||
"description": "Navigation-only CTAs (links) in v1.",
|
||||
"items": { "$ref": "#/$defs/NextStep" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"EvidencePointer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "value"],
|
||||
"properties": {
|
||||
"kind": { "type": "string" },
|
||||
"value": {
|
||||
"description": "Safe pointer value (ID/masked string/hash).",
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"NextStep": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "url"],
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
specs/075-verification-v1-5/data-model.md
Normal file
114
specs/075-verification-v1-5/data-model.md
Normal file
@ -0,0 +1,114 @@
|
||||
# Data Model: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Date**: 2026-02-05
|
||||
**Phase**: Phase 1 (Design)
|
||||
**Status**: Draft (design-complete for implementation planning)
|
||||
|
||||
---
|
||||
|
||||
## Existing Entity (reference)
|
||||
|
||||
### OperationRun (existing)
|
||||
|
||||
**Purpose**: Canonical operational record. Verification reports are stored in `operation_runs.context.verification_report` (JSONB).
|
||||
|
||||
**Key fields (relevant)**:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `type` (verification flow identifier)
|
||||
- `run_identity_hash` (identity hash used for active dedupe + identity matching)
|
||||
- `status`
|
||||
- `context` (JSONB)
|
||||
|
||||
**Verification report storage**:
|
||||
- `context.verification_report` (JSON object)
|
||||
|
||||
---
|
||||
|
||||
## New Persistent Entity
|
||||
|
||||
### VerificationCheckAcknowledgement (new table)
|
||||
|
||||
**Table name**: `verification_check_acknowledgements`
|
||||
|
||||
**Purpose**: First-class governance record that a failing/warning check is acknowledged for a specific report (report == operation run).
|
||||
|
||||
**Fields**:
|
||||
- `id` (primary key)
|
||||
- `tenant_id` (FK or scalar; used for tenant-scoped filtering and isolation checks)
|
||||
- `workspace_id` (FK or scalar)
|
||||
- `operation_run_id` (FK to `operation_runs.id`) — the “report”
|
||||
- `check_key` (string)
|
||||
- `ack_reason` (string, max 160)
|
||||
- `expires_at` (timestamp, nullable) — informational only in v1.5
|
||||
- `acknowledged_at` (timestamp)
|
||||
- `acknowledged_by_user_id` (FK to `users.id`)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Uniqueness constraint (required)**:
|
||||
- unique `(operation_run_id, check_key)`
|
||||
|
||||
**Indexes (recommended)**:
|
||||
- `(tenant_id, workspace_id, operation_run_id)`
|
||||
- `(operation_run_id)`
|
||||
|
||||
**Validation rules**:
|
||||
- `ack_reason`: required, string, length ≤ 160
|
||||
- `expires_at`: optional, must be a valid timestamp, should be >= acknowledged_at (implementation may enforce)
|
||||
|
||||
**State transitions**:
|
||||
- Immutable per report/check in v1.5: create once; no update/delete/unack flows.
|
||||
|
||||
---
|
||||
|
||||
## Contracted Document (stored in JSON)
|
||||
|
||||
### VerificationReport (JSON in `OperationRun.context.verification_report`)
|
||||
|
||||
**Purpose**: Structured, versioned report of verification results used by the DB-only viewer.
|
||||
|
||||
**Identity**:
|
||||
- `report_id`: `operation_runs.id`
|
||||
- `previous_report_id`: previous run id for same identity (nullable)
|
||||
|
||||
**New v1.5 fields**:
|
||||
- `fingerprint` (string; lowercase hex; SHA-256)
|
||||
- `previous_report_id` (nullable integer/uuid depending on `OperationRun` PK type)
|
||||
|
||||
**Existing core fields (from 074, reference)**:
|
||||
- `schema_version` (SemVer string; major `1`)
|
||||
- `flow` (verification flow identifier; aligns with `OperationRun.type`)
|
||||
- `generated_at` (timestamp)
|
||||
- `summary` (counts + overall outcome)
|
||||
- `checks[]` (flat array) including:
|
||||
- `key`
|
||||
- `title`
|
||||
- `status` (`pass|fail|warn|skip|running`)
|
||||
- `severity` (`info|low|medium|high|critical` or empty string)
|
||||
- `blocking` (boolean)
|
||||
- `reason_code` (string)
|
||||
- safe evidence pointers
|
||||
- `next_steps[]` (navigation-only links)
|
||||
|
||||
**Fingerprint normalization input** (strict):
|
||||
- Flatten all checks across `checks[]`.
|
||||
- Sort by `check.key`.
|
||||
- Contribute the stable tuple string:
|
||||
- `key|status|blocking|reason_code|severity`
|
||||
- `severity` must always be present (missing normalized to empty string).
|
||||
|
||||
---
|
||||
|
||||
## Derived/Computed View Data (not persisted)
|
||||
|
||||
### Change indicator
|
||||
|
||||
Computed in the viewer by comparing:
|
||||
- current `verification_report.fingerprint`
|
||||
- previous `verification_report.fingerprint`
|
||||
|
||||
States:
|
||||
- no previous report → no indicator
|
||||
- fingerprints match → “No changes since previous verification”
|
||||
- fingerprints differ → “Changed since previous verification”
|
||||
150
specs/075-verification-v1-5/plan.md
Normal file
150
specs/075-verification-v1-5/plan.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Implementation Plan: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
|
||||
|
||||
**Branch**: `075-verification-v1_5` | **Date**: 2026-02-05 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/075-verification-v1-5/spec.md`
|
||||
|
||||
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
|
||||
|
||||
## Summary
|
||||
|
||||
- Extend the existing 074 verification report system with deterministic **fingerprints** and a **previous report** link so the viewer can show “Changed / No changes”.
|
||||
- Introduce per-check **acknowledgements** as first-class records (unique per report + check) with explicit confirmation and audit logging, without changing outcomes (“no greenwashing”).
|
||||
- Update the Verify step UX to be operator-ready: issues-first tabs, centralized badge semantics (BADGE-001), and exactly one primary CTA depending on state.
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 (Filament v5 requires Livewire v4.0+)
|
||||
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`) + a new acknowledgement table
|
||||
**Testing**: Pest (PHPUnit)
|
||||
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: DB-only viewer renders quickly from stored JSON; fingerprint computation is linear in number of checks (typical report ≤ 50 checks)
|
||||
**Constraints**:
|
||||
- Viewer + Verify step are DB-only at render time (no outbound HTTP / Graph / job dispatch).
|
||||
- All mutations require server-side authorization (RBAC-UX) and explicit confirmation.
|
||||
- Status-like UI must use centralized badge semantics (BADGE-001).
|
||||
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding and provider workflows
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first, snapshots-second: PASS (report/ack UX; no inventory semantics changed).
|
||||
- Read/write separation: PASS (viewer remains read-only; acknowledgements are explicit mutations with confirmation + audit + tests).
|
||||
- Graph contract path: PASS (viewer is DB-only; no new Graph calls added by this feature).
|
||||
- Deterministic capabilities: PASS (capabilities remain centrally registered; no raw strings).
|
||||
- RBAC-UX: PASS (non-member access is 404; member missing capability is 403; server-side enforcement required).
|
||||
- Run observability: PASS (verification remains an `OperationRun`; dedupe while active is unchanged).
|
||||
- Data minimization: PASS (no secrets/tokens; audit payload excludes `ack_reason`).
|
||||
- Badge semantics (BADGE-001): PASS (no new status values; existing verification badge domains remain canonical).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/075-verification-v1-5/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
resources/
|
||||
routes/
|
||||
tests/
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web app with Filament v5 panel. This feature extends verification report writer/viewer, adds an acknowledgement persistence model + migration, and refactors the Verify step UI in Filament.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: [research.md](./research.md)
|
||||
|
||||
Goals covered:
|
||||
- Confirm canonical storage approach for report metadata (keep report in `operation_runs.context`).
|
||||
- Define deterministic fingerprint algorithm and previous report resolution rules.
|
||||
- Define acknowledgement persistence strategy and capability naming reconciliation.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- [data-model.md](./data-model.md)
|
||||
- [contracts/](./contracts/)
|
||||
- [quickstart.md](./quickstart.md)
|
||||
|
||||
Design focus:
|
||||
- Report metadata: add `fingerprint` and `previous_report_id` inside the report JSON.
|
||||
- Previous report resolution: match identity exactly (type/flow + tenant + workspace + provider connection), with `NULL` connection matching only `NULL`.
|
||||
- Acknowledgements: first-class DB table keyed by `(operation_run_id, check_key)`; immutable in v1.5.
|
||||
- Filament UX: issues-first tabs and “one primary CTA” rule; acknowledgements via `Action::make(...)->action(...)` + `->requiresConfirmation()`.
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Data
|
||||
- Migration: create `verification_check_acknowledgements` table with unique `(operation_run_id, check_key)`.
|
||||
- Model: `VerificationCheckAcknowledgement` with tenant/workspace scoping.
|
||||
|
||||
### Report writer / viewer
|
||||
- Extend the report writer to compute and store `fingerprint` and `previous_report_id` (report_id is the run id).
|
||||
- Extend the DB-only viewer to load previous report (when present) and compute the “changed/no-change” indicator.
|
||||
- Ensure the viewer consumes acknowledgements (DB lookup) and groups “Acknowledged issues” separately.
|
||||
|
||||
### Authorization + audit
|
||||
- Capability: add `tenant_verification.acknowledge` to the canonical capability registry and map to roles.
|
||||
- Server-side auth: non-members 404; members without capability 403 for acknowledgement.
|
||||
- Audit: add a new stable action ID (e.g. `verification.check_acknowledged`) with minimal metadata and no `ack_reason`.
|
||||
|
||||
### Filament UX
|
||||
- Verify step: implement the issues-first layout and strict “exactly one primary CTA” rule.
|
||||
- Actions: acknowledgement requires confirmation; navigation-only links remain links-only.
|
||||
- BADGE-001: continue to use centralized badge domains for statuses and summary.
|
||||
|
||||
### Tests (Pest)
|
||||
- Fingerprint determinism: same normalized inputs → same hash; severity-only differences → different hash.
|
||||
- Previous report linking: identity match includes provider connection (`NULL` only matches `NULL`).
|
||||
- RBAC-UX: non-member gets 404; member without capability gets 403 on acknowledgement.
|
||||
- Audit: acknowledgement emits correct action id + minimal metadata (assert `ack_reason` absent).
|
||||
- Viewer DB-only: no outbound HTTP during render/hydration.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. Design keeps report viewing DB-only, introduces a single tenant-scoped mutation with confirmation + audit, preserves RBAC-UX semantics, and maintains BADGE-001 centralized badge rendering.
|
||||
47
specs/075-verification-v1-5/quickstart.md
Normal file
47
specs/075-verification-v1-5/quickstart.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Quickstart: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
This quickstart is for developers implementing and validating Spec 075.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker + Sail
|
||||
- A seeded workspace + tenant and a user that can access the tenant plane (`/admin/t/{tenant}`)
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Run verification (expected UX)
|
||||
|
||||
After implementation, the Verify surface should behave like:
|
||||
|
||||
1. Navigate to the tenant-scoped Verify step (onboarding or equivalent).
|
||||
2. If no active run exists, the single primary CTA is **Start verification**.
|
||||
3. If a run is active, the single primary CTA is **Refresh results**.
|
||||
4. Results default to the **Issues** tab with blockers/failures/warnings ordered first.
|
||||
|
||||
## Acknowledge a check (expected UX)
|
||||
|
||||
After implementation:
|
||||
|
||||
1. On a `fail` or `warn` check card, click **Acknowledge**.
|
||||
2. Confirmation modal appears (required).
|
||||
3. Submit a short reason (≤ 160 chars) and optional expiry (informational only).
|
||||
4. The acknowledgement displays (who/when/reason) and the issue moves into the “Acknowledged issues” group.
|
||||
|
||||
## Authorization expectations
|
||||
|
||||
- Non-members attempting to access tenant-scoped verification pages: deny-as-not-found (404).
|
||||
- Tenant members without `tenant_verification.acknowledge`: acknowledgement attempts fail with 403.
|
||||
|
||||
## Run the focused test suite
|
||||
|
||||
Once tests are implemented:
|
||||
|
||||
- Run only the spec-related tests: `vendor/bin/sail artisan test --compact --filter=Verification` (or point at the specific test file(s)).
|
||||
|
||||
## Formatting
|
||||
|
||||
- Format only changed files before finalizing: `vendor/bin/sail bin pint --dirty`
|
||||
119
specs/075-verification-v1-5/research.md
Normal file
119
specs/075-verification-v1-5/research.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Research: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Date**: 2026-02-05
|
||||
**Phase**: Phase 0 (Foundational Research)
|
||||
**Status**: Complete
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-075-001 — Canonical storage for report + metadata
|
||||
|
||||
**Decision**: Store the verification report (including `fingerprint` and `previous_report_id`) inside `operation_runs.context.verification_report` (JSONB), consistent with 074.
|
||||
|
||||
**Rationale**:
|
||||
- Viewer surfaces must be DB-only at render time (constitution: Operations / Run Observability Standard).
|
||||
- `OperationRun` is already the canonical operational record and stable viewer entry point.
|
||||
- Adds supportability metadata without introducing a new top-level report table.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Dedicated `verification_reports` table: rejected for v1.5 to avoid new query/index surfaces; revisit if we need global querying across reports.
|
||||
|
||||
---
|
||||
|
||||
### D-075-002 — Report identity + “previous report” resolution
|
||||
|
||||
**Decision**: Resolve `previous_report_id` by querying the most recent earlier `OperationRun` whose **run identity** matches exactly (flow/type, tenant, workspace, provider connection where applicable).
|
||||
|
||||
**Rationale**:
|
||||
- The existing `OperationRunService::ensureRunWithIdentity()` + `run_identity_hash` already defines the dedupe boundary.
|
||||
- Matches the spec’s clarified rule: `provider_connection_id` must match exactly; `NULL` only matches `NULL`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Match previous runs by only `tenant_id + workspace_id + type` and then filter in PHP: rejected due to ambiguity and risk of cross-connection mixing.
|
||||
|
||||
---
|
||||
|
||||
### D-075-003 — Report ID semantics
|
||||
|
||||
**Decision**: Treat the `OperationRun` ID as the report identifier in UX and contracts (`report_id == operation_run_id`).
|
||||
|
||||
**Rationale**:
|
||||
- The report is attached to the run; the run is the stable, tenant-scoped canonical record.
|
||||
- Avoids a second identifier for the same “verification execution artifact”.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Generate a separate report UUID inside the JSON: rejected as it adds indirection without benefits in v1.5.
|
||||
|
||||
---
|
||||
|
||||
### D-075-004 — Fingerprint algorithm
|
||||
|
||||
**Decision**: Use SHA-256 over a deterministic normalization of check outcomes:
|
||||
- flatten checks
|
||||
- sort by `check.key`
|
||||
- contribute `key|status|blocking|reason_code|severity` where `severity` is always present (missing → empty)
|
||||
|
||||
Store as lowercase hex.
|
||||
|
||||
**Rationale**:
|
||||
- Deterministic across environments.
|
||||
- Treats severity-only changes as meaningful (per clarified requirement).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hash the full report JSON: rejected (unstable ordering, non-semantic fields like timestamps).
|
||||
|
||||
---
|
||||
|
||||
### D-075-005 — Per-check acknowledgements persistence
|
||||
|
||||
**Decision**: Create a first-class table `verification_check_acknowledgements` keyed by `(operation_run_id, check_key)` with a unique constraint.
|
||||
|
||||
**Rationale**:
|
||||
- Acknowledgements are governance metadata and must be queryable and auditable.
|
||||
- Unique per report/check is enforced by the DB.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store acknowledgements inside `operation_runs.context`: rejected as it complicates update semantics and auditability, and risks “report mutation” appearing like a changed verification outcome.
|
||||
|
||||
---
|
||||
|
||||
### D-075-006 — Capability naming reconciliation
|
||||
|
||||
**Decision**: Introduce a dedicated canonical capability `tenant_verification.acknowledge` in the capability registry and map it in the role → capability map.
|
||||
|
||||
**Rationale**:
|
||||
- Keeps the feature spec requirement literal and avoids overloading “findings” semantics.
|
||||
- Preserves the constitution rule that capabilities are centrally registered (no raw strings).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse existing `tenant_findings.acknowledge`: rejected because this feature is specifically verification-report scoped, and we want the permission surface to remain explicit.
|
||||
|
||||
---
|
||||
|
||||
### D-075-007 — Audit action identifier + payload minimization
|
||||
|
||||
**Decision**: Add a stable audit action ID for acknowledgements (e.g. `verification.check_acknowledged`) and emit it on successful acknowledgement. Audit metadata is minimal and MUST NOT include `ack_reason`.
|
||||
|
||||
**Rationale**:
|
||||
- Acknowledgement is a write mutation; constitution requires audit logging.
|
||||
- Spec explicitly excludes `ack_reason` from audit payload; it remains only in the acknowledgement record.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse `verification.completed`: rejected because it conflates verification execution with governance mutation.
|
||||
|
||||
---
|
||||
|
||||
### D-075-008 — Filament UI implementation constraints
|
||||
|
||||
**Decision**: Implement the “Verify step” UX changes in Filament v5 (Livewire v4) using:
|
||||
- DB-only viewer helper (no external calls)
|
||||
- centralized badge domains (BADGE-001)
|
||||
- mutation via Filament `Action::make(...)->action(...)` with `->requiresConfirmation()`
|
||||
|
||||
**Rationale**:
|
||||
- Aligns with Filament v5 patterns and constitution rules.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Publish/override Filament internal views: rejected; prefer render hooks + CSS hooks as needed.
|
||||
225
specs/075-verification-v1-5/spec.md
Normal file
225
specs/075-verification-v1-5/spec.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Feature Specification: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
|
||||
|
||||
**Feature Branch**: `075-verification-v1_5`
|
||||
**Created**: 2026-02-05
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Extend verification checklist framework with report fingerprint + previous report change indicator, per-check acknowledgements with audit/confirmation, and issues-first operator-ready verify-step UX."
|
||||
|
||||
## Goal
|
||||
|
||||
V1.5 extends the V1 verification checklist framework with two enterprise-critical additions while keeping scope intentionally small:
|
||||
|
||||
1) **Supportability / determinism**: show whether results changed since the previous verification for the same identity.
|
||||
2) **Governance**: allow explicit, auditable acknowledgement of known issues per failing check.
|
||||
3) **Enterprise UX completeness**: make the Verify step operator-ready (issues-first, one clear primary action, technical details secondary).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-05
|
||||
|
||||
- Q: How is “block” represented on checks? → A: No new status; a “Blocker” is `status=fail` with `blocking=true`.
|
||||
- Q: Should `severity` be part of the fingerprint? → A: Yes; include `severity` always (normalize missing to empty) to keep hashing deterministic and to treat severity-only changes as “Changed”.
|
||||
- Q: Should `ack_reason` be included in the audit event payload? → A: No; keep audit metadata minimal and store the reason only in the acknowledgement record.
|
||||
- Q: How should `provider_connection_id` be treated when resolving `previous_report_id`? → A: Match exactly; `NULL` only matches `NULL` (no cross-connection mixing).
|
||||
- **Report shape (canonical, inherited from 074)**: Persist reports as the existing V1 JSON shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`). V1.5 adds `fingerprint` + `previous_report_id` at the top level. No `sections[]` array is stored.
|
||||
- **Idempotency (inherited)**: Deduplication applies only while a run is active (`queued` / `running`). Once `completed` / `failed`, starting verification creates a new run.
|
||||
- **Viewing (inherited)**: Viewing is DB-only; rendering MUST NOT perform external calls.
|
||||
- **Evidence (inherited)**: Evidence is limited to safe pointers only; no secrets (no tokens/claims/headers/raw payloads).
|
||||
- **Next steps (inherited)**: Navigation-only links (no server-side “fix it” actions from the viewer).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Operator can tell “nothing changed” (Priority: P1)
|
||||
|
||||
As an operator, I can immediately see whether the current verification findings are unchanged compared to the previous verification for the same identity, so I can avoid unnecessary re-diagnosis.
|
||||
|
||||
**Why this priority**: This is the fastest path to supportability: it reduces repeated analysis and makes troubleshooting deterministic.
|
||||
|
||||
**Independent Test**: Create two reports for the same identity with identical normalized check outcomes; confirm the viewer indicates “No changes since previous verification”.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a report with a previous report available, **When** I open the viewer, **Then** I see a clear indicator “Changed” or “No changes”.
|
||||
2. **Given** the current report has the same fingerprint as the previous report, **When** I open the viewer, **Then** I see “No changes since previous verification”.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Owner/Manager can acknowledge a known issue (Priority: P1)
|
||||
|
||||
As an owner/manager, I can acknowledge a failing/warning/blocking check with a short reason (and optionally an expiry) so the team can see that the risk is known, evaluated, and accepted.
|
||||
|
||||
**Why this priority**: Acknowledgements provide governance without masking risk; they improve shared context and auditability.
|
||||
|
||||
**Independent Test**: With and without the acknowledgement capability, attempt to acknowledge a failing check; assert correct authorization (403) and that an audit event is recorded for the successful path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a check in status `fail` / `warn` (including failing blockers where `blocking=true`), **When** I acknowledge it with a reason, **Then** the UI shows who acknowledged it, when, and the reason.
|
||||
2. **Given** I do not have the acknowledgement capability, **When** I attempt to acknowledge a check, **Then** the server returns 403 and the UI does not offer the acknowledgement action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Verify step is operator-ready (issues-first) (Priority: P1)
|
||||
|
||||
As a workspace member, I see issues-first results with clear next steps and exactly one primary action (start or refresh), so I can remediate quickly without hunting through technical details.
|
||||
|
||||
**Why this priority**: The Verify step is a high-frequency operator surface; clarity and deterministic states reduce time-to-resolution.
|
||||
|
||||
**Independent Test**: Seed a report with blockers and a running state; confirm the default tab and “one primary CTA” rule is enforced in both completed and running scenarios.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a report with blockers, **When** I open the Verify step/viewer, **Then** the Issues tab is the default and blockers are at the top.
|
||||
2. **Given** a run is active, **When** I open the Verify step/viewer, **Then** the primary action is “Refresh results” and technical links are secondary.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- No previous report exists for an identity → no “changed/no-change” indicator is shown.
|
||||
- Run is active but no report is available yet → UI shows a clear “running, results will appear” explanation (no empty states without guidance).
|
||||
- Partial report while running → partial results render with a “Partial results” label.
|
||||
- Unknown check keys or reason codes → UI degrades gracefully, showing status and message without breaking.
|
||||
- Acknowledgement attempted for non-acknowledgeable status (e.g., `pass`) → request is rejected and UI does not offer it.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Diff/compare UI between reports
|
||||
- Server-side fixes initiated from the viewer
|
||||
- Undo / unacknowledge acknowledgements (V1.5 acknowledgements are immutable per report)
|
||||
- Complex staleness/TTL semantics (fresh/stale/expired)
|
||||
- Global dashboards / cross-tenant reporting
|
||||
- Export features (PDF/JSON) as a product feature
|
||||
- Live polling (V1.5 uses manual refresh)
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature adds new tenant-scoped mutations (acknowledgements) and new report metadata. It MUST include explicit confirmation, audit logging for mutations, tenant isolation, and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Tenant-scoped routes MUST preserve deny-as-not-found (404) for non-members, and use 403 for members missing a capability. UI visibility is not authorization; server-side enforcement is required.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Status-like badges MUST use centralized mapping semantics; no ad-hoc UI mappings.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-075-001 — Report fingerprint**: Each verification report MUST store a deterministic `fingerprint` derived from normalized check outcomes.
|
||||
|
||||
**Normalization rule (deterministic):**
|
||||
- Flatten all check results across `report.checks[]`
|
||||
- Sort by stable `check.key`
|
||||
- For each check, contribute a stable string using: `key | status | blocking | reason_code | severity`
|
||||
- `severity` MUST be included always; if the source report omits it, normalize to an empty string
|
||||
- The fingerprint MUST be a stable cryptographic hash of the joined contributions, stored as a fixed-length lowercase hex string.
|
||||
|
||||
- **FR-075-002 — Previous report link**: Each report MUST store `previous_report_id` (nullable) that points to the most recent earlier report for the same **verification identity**.
|
||||
|
||||
**Identity match** MUST include:
|
||||
- flow
|
||||
- workspace
|
||||
- tenant
|
||||
- provider connection (`provider_connection_id`) matched exactly; `NULL` only matches `NULL`
|
||||
|
||||
- **FR-075-003 — Change indicator**: When a previous report exists, the viewer MUST show:
|
||||
- “No changes since previous verification” if `fingerprint` matches
|
||||
- “Changed since previous verification” otherwise
|
||||
|
||||
- **FR-075-004 — Per-check acknowledgements (first-class)**: The system MUST allow acknowledging checks with status `fail` / `warn`.
|
||||
|
||||
An acknowledgement MUST record:
|
||||
- reason (max 160 characters)
|
||||
- acknowledged timestamp
|
||||
- acknowledged-by user
|
||||
- optional expiry timestamp
|
||||
|
||||
Acknowledgements MUST be unique per (report, check key). Expiry, when provided, is informational only in V1.5 and MUST NOT introduce automatic staleness/TTL behavior.
|
||||
|
||||
- **FR-075-005 — Acknowledgement does not change outcomes**: Acknowledging MUST NOT change:
|
||||
- the check status
|
||||
- the report summary status/outcome
|
||||
- the run outcome
|
||||
|
||||
- **FR-075-006 — Acknowledgement allowed conditions**: Acknowledgement MUST only be possible for checks whose status is in `{fail, warn}`. It MUST NOT be available for passing/green checks.
|
||||
|
||||
A check is considered a **Blocker** when `status=fail` and `blocking=true`; blockers are acknowledgeable under the same `{fail, warn}` rule (no separate `block` status exists).
|
||||
|
||||
- **FR-075-007 — Acknowledgement authorization (capability-first)**: Acknowledgement MUST require the capability `tenant_verification.acknowledge` as defined in the canonical capability registry.
|
||||
|
||||
RBAC UX semantics:
|
||||
- non-member / not entitled to tenant scope → 404
|
||||
- member without acknowledgement capability → 403
|
||||
- members with tenant scope but without acknowledgement capability can still view reports (view remains read-only)
|
||||
|
||||
- **FR-075-007A — Viewing authorization semantics preserved (inherited)**: Viewing tenant-scoped verification pages (Verify step + report viewer) MUST preserve V1 semantics:
|
||||
- non-member / not entitled to tenant scope → 404
|
||||
- member with tenant scope → can view
|
||||
- capability checks apply to mutations only (start verification, acknowledgement)
|
||||
|
||||
- **FR-075-008 — Confirmation + audit required**: Acknowledgement is a mutation and MUST require explicit user confirmation and MUST emit an audit event.
|
||||
|
||||
- **FR-075-009 — Audit event metadata (minimal)**: The audit event for acknowledgement MUST include minimally:
|
||||
- workspace, tenant, run, report, flow
|
||||
- check key and reason code
|
||||
- acknowledged-by user
|
||||
|
||||
It MUST NOT include `ack_reason`, secrets, tokens, or raw payloads.
|
||||
|
||||
- **FR-075-010 — DB-only viewing guard (inherited)**: Rendering the viewer and the Verify step MUST NOT trigger external calls.
|
||||
|
||||
- **FR-075-011 — Centralized badge semantics (BADGE-001)**: All check-status badges and summary-status badges used by V1.5 MUST use the centralized badge mapping registry.
|
||||
|
||||
- **FR-075-012 — Verify step enterprise UX (normative)**: The Verify step/viewer MUST follow an issues-first layout and deterministic UI states:
|
||||
|
||||
**Structure**
|
||||
- Always-visible summary card
|
||||
- Tabs: Issues (default), Passed, Technical details
|
||||
|
||||
**DB-only hint**
|
||||
- The summary surface MUST include a clear hint that viewing is read-only and performs no external calls.
|
||||
|
||||
**Primary action rule (strict)**
|
||||
- Exactly one primary call-to-action is shown at any time
|
||||
- “Start verification” and “Refresh results” MUST NOT both be primary simultaneously
|
||||
|
||||
**Issues tab ordering**
|
||||
1) Blockers (not acknowledged)
|
||||
2) Failures (not acknowledged)
|
||||
3) Warnings (not acknowledged)
|
||||
4) Acknowledged issues (collapsed group)
|
||||
|
||||
**Next steps rendering**
|
||||
- Max 2 navigation-only links per issue card
|
||||
- “Open run details” MUST appear only in Technical details (not in issue cards)
|
||||
|
||||
**Technical details**
|
||||
- Secondary surface that can show identifiers (run/report IDs), fingerprint, and previous report link
|
||||
- No raw payloads/tokens/full error bodies
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Verification Identity**: The stable identifiers that define “what is being verified” (flow, workspace, tenant, and optional provider connection).
|
||||
- **Verification Report**: A structured record of verification outcomes for a run.
|
||||
- **Report Fingerprint**: A deterministic hash representing normalized check outcomes.
|
||||
- **Previous Report**: The immediately preceding report for the same identity.
|
||||
- **Check Acknowledgement**: A governance record that an issue is known/accepted (who/when/reason/optional expiry) without altering the check outcome.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- A verification run/report concept already exists from V1.
|
||||
- The system has an audit log mechanism capable of recording acknowledgement actions.
|
||||
- Manual refresh is acceptable (no polling required).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Spec 074 (Verification Checklist Framework V1)
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-075-001 (Supportability)**: With a previous report present, operators can determine “changed vs no changes” within 10 seconds in 95% of tested sessions.
|
||||
- **SC-075-002 (Governance)**: 100% of successful acknowledgements create an audit log record with minimal metadata and no sensitive content.
|
||||
- **SC-075-003 (UX determinism)**: The Verify step renders exactly one primary CTA in all tested UI states (not started, running with/without report, completed).
|
||||
- **SC-075-004 (Authorization correctness)**: Non-members receive 404 for tenant-scoped access routes in 100% of tests; members without acknowledgement capability receive 403 for acknowledgement attempts in 100% of tests.
|
||||
- **SC-075-005 (No greenwashing)**: Acknowledging an issue never changes check status or the report summary in any tested scenario.
|
||||
|
||||
```
|
||||
172
specs/075-verification-v1-5/tasks.md
Normal file
172
specs/075-verification-v1-5/tasks.md
Normal file
@ -0,0 +1,172 @@
|
||||
---
|
||||
|
||||
description: "Task breakdown for Spec 075 (Verification Checklist Framework V1.5)"
|
||||
---
|
||||
|
||||
# Tasks: Verification Checklist Framework V1.5 (075)
|
||||
|
||||
**Input**: Design documents from `/specs/075-verification-v1-5/`
|
||||
|
||||
**Tests**: REQUIRED (Pest)
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Align feature artifacts with the existing 074 verification implementation (report shape, DB-only viewing constraints).
|
||||
|
||||
- [X] T001 Reconcile v1.5 report contract to match the V1 report shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`) + v1.5 fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json
|
||||
- [X] T002 [P] Confirm viewer surfaces are DB-only using existing guard helpers in tests/Support/AssertsNoOutboundHttp.php (helper availability + correct usage patterns)
|
||||
- [X] T003 [P] Identify all report viewer templates to update: resources/views/filament/components/verification-report-viewer.blade.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required by all user stories (schema/sanitization, stable fingerprint, previous report resolution).
|
||||
|
||||
**⚠️ CRITICAL**: Complete this phase before implementing US1/US2/US3.
|
||||
|
||||
- [X] T004 Update report schema to allow v1.5 metadata fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSchema.php
|
||||
- [X] T005 Update report sanitizer to preserve v1.5 metadata fields (`fingerprint`, `previous_report_id`) and preserve empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSanitizer.php
|
||||
- [X] T006 [P] Add a deterministic fingerprint helper in app/Support/Verification/VerificationReportFingerprint.php (flatten `checks[]`; normalize missing `severity` to empty string, not `info`)
|
||||
- [X] T007 Add a previous-report resolver helper in app/Support/Verification/PreviousVerificationReportResolver.php
|
||||
- [X] T008 [P] Add or update verification badge mapping tests in tests/Feature/Badges/ to cover all v1.5-used status-like values (BADGE-001)
|
||||
|
||||
**Checkpoint**: Schema + sanitizer accept v1.5 fields; fingerprint + previous-report resolver are available for use.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Operator can tell “nothing changed” (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Persist a deterministic `fingerprint` + `previous_report_id` on each report, and show “Changed / No changes” when a previous report exists.
|
||||
|
||||
**Independent Test**: Create two completed verification runs for the same identity with identical normalized outcomes; confirm viewer indicates “No changes since previous verification”.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T009 [P] [US1] Add fingerprint determinism unit tests in tests/Feature/Verification/VerificationReportFingerprintTest.php (including missing severity → empty string, and severity-only changes → different hash)
|
||||
- [X] T010 [P] [US1] Add previous report identity matching tests (provider_connection_id exact match; NULL matches NULL) and a regression proving cross-connection runs don’t match when run_identity_hash includes provider_connection_id in tests/Feature/Verification/PreviousVerificationReportResolverTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Compute and persist report fingerprint in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/VerificationReportFingerprint.php)
|
||||
- [X] T012 [US1] Resolve and persist previous_report_id during write in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/PreviousVerificationReportResolver.php + run_identity_hash; verify all verification run start paths include provider_connection_id in identityInputs)
|
||||
- [X] T013 [P] [US1] Extend DB-only report viewer helper to expose v1.5 metadata in app/Filament/Support/VerificationReportViewer.php
|
||||
- [X] T014 [US1] Add change-indicator computation for viewer surfaces in app/Filament/Support/VerificationReportChangeIndicator.php
|
||||
|
||||
**Checkpoint**: Report JSON includes `fingerprint` + `previous_report_id`; viewer can derive Changed/No changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Owner/Manager can acknowledge a known issue (Priority: P1)
|
||||
|
||||
**Goal**: Acknowledge `fail` / `warn` checks per report with confirmation + audit, without changing check outcomes.
|
||||
|
||||
**Independent Test**: Attempt to acknowledge a failing check (a) as non-member → 404, (b) as member without capability → 403, (c) with capability → record created + audit logged.
|
||||
|
||||
### Tests for User Story 2 (write first)
|
||||
|
||||
- [X] T015 [P] [US2] Add acknowledgement authorization + audit tests in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php (404 non-member, 403 missing capability, persists optional expires_at; audit metadata includes check_key + reason_code and excludes ack_reason)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Create migration for verification_check_acknowledgements table (includes optional expires_at; informational only) in database/migrations/*_create_verification_check_acknowledgements_table.php
|
||||
- [X] T017 [P] [US2] Create model in app/Models/VerificationCheckAcknowledgement.php
|
||||
- [X] T018 [P] [US2] Create factory for acknowledgements in database/factories/VerificationCheckAcknowledgementFactory.php
|
||||
- [X] T019 [US2] Implement acknowledgement creation service in app/Services/Verification/VerificationCheckAcknowledgementService.php (server-side authorization via Gate/policy; validate status ∈ {fail,warn}; validate optional expires_at; enforce unique per (operation_run_id, check_key))
|
||||
- [X] T020 [P] [US2] Register capability constant tenant_verification.acknowledge in app/Support/Auth/Capabilities.php
|
||||
- [X] T021 [P] [US2] Map tenant_verification.acknowledge to tenant roles in app/Services/Auth/RoleCapabilityMap.php
|
||||
- [X] T022 [P] [US2] Add audit action id for acknowledgement in app/Support/Audit/AuditActionId.php (e.g. verification.check_acknowledged)
|
||||
- [X] T023 [US2] Emit audit event with minimal metadata via app/Services/Audit/WorkspaceAuditLogger.php from the acknowledgement path (MUST include: tenant_id, operation_run_id/report_id, flow, check_key, reason_code; MUST NOT include ack_reason)
|
||||
|
||||
**Checkpoint**: Acknowledgements are persisted, authorized, confirmed in UI (next story), and audited with minimized metadata.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Verify step is operator-ready (issues-first) (Priority: P1)
|
||||
|
||||
**Goal**: Issues-first view, centralized badge semantics (BADGE-001), DB-only hint, and exactly one primary CTA depending on state.
|
||||
|
||||
**Independent Test**: Seed a run with blockers while completed and while running; confirm Issues is default, ordering rules hold, and one-primary-CTA rule holds.
|
||||
|
||||
### Tests for User Story 3 (write first)
|
||||
|
||||
- [X] T024 [P] [US3] Add Verify-step CTA and ordering tests in tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
|
||||
- [X] T025 [P] [US3] Add DB-only render guard test coverage for Verify surfaces in tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Enforce “exactly one primary CTA” logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start vs refresh)
|
||||
- [X] T027 [US3] Refactor Verify-step report view to issues-first tabs + ordering + DB-only hint in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
|
||||
- [X] T028 [US3] Add per-check acknowledgement action UI with confirmation in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Action::make(...)->action(...)->requiresConfirmation())
|
||||
- [X] T029 [US3] Wire acknowledgement UI to service + RBAC semantics (404 non-member, 403 missing capability; server-side enforcement required) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
- [X] T030 [US3] Update the Monitoring viewer to match v1.5 UX rules (issues-first tabs: Issues default, Passed, Technical details; ordering; next-steps max 2) in resources/views/filament/components/verification-report-viewer.blade.php
|
||||
- [X] T031 [P] [US3] Show change indicator + previous report link in technical details (no raw payloads) in resources/views/filament/components/verification-report-viewer.blade.php
|
||||
|
||||
**Checkpoint**: Verify UX is deterministic, issues-first, and operator-ready across onboarding and monitoring surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Hardening, formatting, and regression coverage.
|
||||
|
||||
- [X] T032 [P] Ensure acknowledgement does not mutate check status/summary in app/Support/Verification/VerificationReportWriter.php and cover with assertions in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php
|
||||
- [X] T033 [P] Add redaction regression checks for new v1.5 fields (fingerprint/previous_report_id) in tests/Feature/Verification/VerificationReportRedactionTest.php
|
||||
- [X] T034 [P] Run Pint on changed files via vendor/bin/sail bin pint --dirty
|
||||
- [X] T035 Run focused test suite via vendor/bin/sail artisan test --compact --filter=Verification
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: start immediately
|
||||
- **Foundational (Phase 2)**: blocks all user stories
|
||||
- **User Stories (Phase 3–5)**:
|
||||
- US1 depends on Phase 2
|
||||
- US2 depends on Phase 2
|
||||
- US3 depends on Phase 2 and benefits from US1 + US2 completion
|
||||
- **Polish (Phase 6)**: after US1–US3
|
||||
|
||||
### User Story Dependencies (Graph)
|
||||
|
||||
- **US1 (Fingerprint + previous report + changed indicator)** → enables technical details and “Changed/No changes” banner in US3
|
||||
- **US2 (Acknowledgements)** → enables “Acknowledged issues” grouping and action UX in US3
|
||||
- **US3 (Verify UX)** → integrates outputs of US1 + US2 into operator surface
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- Run in parallel:
|
||||
- T009 (fingerprint determinism tests) + T010 (previous resolver tests)
|
||||
- T013 (viewer helper exposure) can proceed while T011/T012 land
|
||||
|
||||
### US2
|
||||
|
||||
- Run in parallel:
|
||||
- T017 (model) + T018 (factory) + T020 (capability constant) + T021 (role mapping) + T022 (audit action id)
|
||||
|
||||
### US3
|
||||
|
||||
- Run in parallel:
|
||||
- T024 (UX tests) + T025 (DB-only tests)
|
||||
- T027 (onboarding blade refactor) + T030 (monitoring viewer refactor)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1)
|
||||
|
||||
1. Phase 1 → Phase 2
|
||||
2. Implement US1 (Phase 3)
|
||||
3. Validate: run T035 and confirm “No changes since previous verification” path
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. US1 (supportability) → US2 (governance) → US3 (operator UX)
|
||||
2. After each story, run story-specific tests plus `vendor/bin/sail artisan test --compact --filter=Verification`
|
||||
47
tests/Feature/Badges/VerificationBadgeSemanticsTest.php
Normal file
47
tests/Feature/Badges/VerificationBadgeSemanticsTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps verification check status fail to a Fail danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
|
||||
|
||||
expect($spec->label)->toBe('Fail');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('normalizes verification check status input before mapping', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'RUNNING');
|
||||
|
||||
expect($spec->label)->toBe('Running');
|
||||
});
|
||||
|
||||
it('maps verification check severity critical to a Critical danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
|
||||
|
||||
expect($spec->label)->toBe('Critical');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('maps empty verification check severity to an Unknown badge (v1.5 allows empty severity)', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, '');
|
||||
|
||||
expect($spec->label)->toBe('Unknown');
|
||||
});
|
||||
|
||||
it('maps verification report overall needs_attention to a Needs attention warning badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
|
||||
|
||||
expect($spec->label)->toBe('Needs attention');
|
||||
expect($spec->color)->toBe('warning');
|
||||
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
|
||||
});
|
||||
|
||||
it('normalizes verification report overall input before mapping', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'NEEDS ATTENTION');
|
||||
|
||||
expect($spec->label)->toBe('Needs attention');
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -102,13 +103,19 @@
|
||||
'entra_tenant_id' => $entraTenantId,
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
],
|
||||
'failure_summary' => [
|
||||
[
|
||||
'code' => 'provider.connection.check.failed',
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
],
|
||||
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'permission_check',
|
||||
'title' => 'Graph permissions',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Missing required Graph permissions.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
@ -127,7 +134,7 @@
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('permission_denied')
|
||||
->assertSee('Missing required Graph permissions.')
|
||||
->assertSee('Graph permissions')
|
||||
->assertSee($entraTenantId);
|
||||
});
|
||||
|
||||
222
tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
Normal file
222
tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
Normal file
@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('shows exactly one verification CTA depending on run state', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Start verification')
|
||||
->assertDontSee('Refresh');
|
||||
|
||||
$runningRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update([
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $runningRun->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Refresh')
|
||||
->assertDontSee('Start verification');
|
||||
});
|
||||
|
||||
it('orders issues deterministically and groups acknowledged issues', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$checks = [
|
||||
[
|
||||
'key' => 'acknowledged_fail',
|
||||
'title' => 'Acked failure',
|
||||
'status' => 'fail',
|
||||
'severity' => 'medium',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'invalid_state',
|
||||
'message' => 'Already known.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'warning',
|
||||
'title' => 'Warning check',
|
||||
'status' => 'warn',
|
||||
'severity' => 'low',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'not_applicable',
|
||||
'message' => 'Something is slightly off.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'failure',
|
||||
'title' => 'Failure check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'missing_configuration',
|
||||
'message' => 'This must be fixed.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
[
|
||||
'key' => 'blocker',
|
||||
'title' => 'Blocker check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'critical',
|
||||
'blocking' => true,
|
||||
'reason_code' => 'permission_denied',
|
||||
'message' => 'Cannot proceed.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [
|
||||
['label' => 'First step', 'url' => '/admin/help/first'],
|
||||
['label' => 'Second step', 'url' => '/admin/help/second'],
|
||||
['label' => 'Third step', 'url' => '/admin/help/third'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'pass',
|
||||
'title' => 'Passed check',
|
||||
'status' => 'pass',
|
||||
'severity' => '',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'ok',
|
||||
'message' => 'Looks good.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', $checks);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
VerificationCheckAcknowledgement::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'check_key' => 'acknowledged_fail',
|
||||
'ack_reason' => 'Known issue accepted.',
|
||||
'acknowledged_by_user_id' => (int) $user->getKey(),
|
||||
'acknowledged_at' => now(),
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Read-only')
|
||||
->assertSeeInOrder([
|
||||
'Blocker check',
|
||||
'Failure check',
|
||||
'Warning check',
|
||||
'Acknowledged issues',
|
||||
'Acked failure',
|
||||
])
|
||||
->assertSee('Known issue accepted.')
|
||||
->assertSee('First step')
|
||||
->assertSee('Second step')
|
||||
->assertDontSee('Third step');
|
||||
});
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\PreviousVerificationReportResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('resolves the previous report id for the same identity (including provider_connection_id)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connectionId = (int) ProviderConnection::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
])->getKey();
|
||||
|
||||
$previous = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'run_identity_hash' => 'same-hash',
|
||||
'context' => [
|
||||
'provider_connection_id' => $connectionId,
|
||||
],
|
||||
]);
|
||||
|
||||
$current = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'run_identity_hash' => 'same-hash',
|
||||
'context' => [
|
||||
'provider_connection_id' => $connectionId,
|
||||
],
|
||||
]);
|
||||
|
||||
expect(PreviousVerificationReportResolver::resolvePreviousReportId($current))
|
||||
->toBe((int) $previous->getKey());
|
||||
});
|
||||
|
||||
it('does not resolve previous report ids across provider connections', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connectionA = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey();
|
||||
$connectionB = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'run_identity_hash' => 'same-hash',
|
||||
'context' => [
|
||||
'provider_connection_id' => $connectionA,
|
||||
],
|
||||
]);
|
||||
|
||||
$current = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'run_identity_hash' => 'same-hash',
|
||||
'context' => [
|
||||
'provider_connection_id' => $connectionB,
|
||||
],
|
||||
]);
|
||||
|
||||
expect(PreviousVerificationReportResolver::resolvePreviousReportId($current))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
it('includes provider_connection_id in the verification run identity hash (no cross-connection dedupe)', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connectionA = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
]);
|
||||
|
||||
$connectionB = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
]);
|
||||
|
||||
$starter = app(StartVerification::class);
|
||||
|
||||
$runA = $starter->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $connectionA,
|
||||
initiator: $user,
|
||||
)->run->refresh();
|
||||
|
||||
$runB = $starter->providerConnectionCheck(
|
||||
tenant: $tenant,
|
||||
connection: $connectionB,
|
||||
initiator: $user,
|
||||
)->run->refresh();
|
||||
|
||||
expect($runA->getKey())->not->toBe($runB->getKey());
|
||||
expect($runA->run_identity_hash)->not->toBe($runB->run_identity_hash);
|
||||
});
|
||||
|
||||
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
it('returns 404 for non-members on verification check acknowledgement', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'readonly');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'verification_report' => json_decode(
|
||||
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
||||
tenant: $tenant,
|
||||
run: $run,
|
||||
checkKey: 'provider_connection.token_acquisition',
|
||||
ackReason: 'Known issue',
|
||||
expiresAt: null,
|
||||
actor: $user,
|
||||
))->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
it('returns 403 for members without tenant_verification.acknowledge on verification check acknowledgement', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'verification_report' => json_decode(
|
||||
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
||||
tenant: $tenant,
|
||||
run: $run,
|
||||
checkKey: 'provider_connection.token_acquisition',
|
||||
ackReason: 'Known issue',
|
||||
expiresAt: null,
|
||||
actor: $user,
|
||||
))->toThrow(AuthorizationException::class);
|
||||
});
|
||||
|
||||
it('acknowledges a failing check (with optional expiry) and writes a minimal audit log (no ack_reason)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'verification_report' => json_decode(
|
||||
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
$reportBefore = $run->context['verification_report'] ?? null;
|
||||
$reportBefore = is_array($reportBefore) ? $reportBefore : [];
|
||||
|
||||
$summaryBefore = is_array($reportBefore['summary'] ?? null) ? $reportBefore['summary'] : [];
|
||||
$countsBefore = is_array($summaryBefore['counts'] ?? null) ? $summaryBefore['counts'] : [];
|
||||
|
||||
$checksBefore = is_array($reportBefore['checks'] ?? null) ? $reportBefore['checks'] : [];
|
||||
$checksBeforeByKey = collect($checksBefore)
|
||||
->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null))
|
||||
->mapWithKeys(fn (array $check): array => [
|
||||
(string) $check['key'] => [
|
||||
'status' => $check['status'] ?? null,
|
||||
'blocking' => $check['blocking'] ?? null,
|
||||
'severity' => $check['severity'] ?? null,
|
||||
'reason_code' => $check['reason_code'] ?? null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
|
||||
$fingerprintBefore = VerificationReportFingerprint::forReport($reportBefore);
|
||||
|
||||
$expiresAt = now()->addDay()->toISOString();
|
||||
|
||||
$ack = app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
||||
tenant: $tenant,
|
||||
run: $run,
|
||||
checkKey: 'provider_connection.token_acquisition',
|
||||
ackReason: 'Known issue',
|
||||
expiresAt: $expiresAt,
|
||||
actor: $user,
|
||||
);
|
||||
|
||||
expect($ack->operation_run_id)->toBe((int) $run->getKey());
|
||||
expect($ack->check_key)->toBe('provider_connection.token_acquisition');
|
||||
expect($ack->ack_reason)->toBe('Known issue');
|
||||
expect($ack->expires_at)->not->toBeNull();
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$reportAfter = $run->context['verification_report'] ?? null;
|
||||
$reportAfter = is_array($reportAfter) ? $reportAfter : [];
|
||||
|
||||
$summaryAfter = is_array($reportAfter['summary'] ?? null) ? $reportAfter['summary'] : [];
|
||||
$countsAfter = is_array($summaryAfter['counts'] ?? null) ? $summaryAfter['counts'] : [];
|
||||
|
||||
expect($summaryAfter['overall'] ?? null)->toBe($summaryBefore['overall'] ?? null);
|
||||
expect($countsAfter)->toBe($countsBefore);
|
||||
|
||||
$checksAfter = is_array($reportAfter['checks'] ?? null) ? $reportAfter['checks'] : [];
|
||||
$checksAfterByKey = collect($checksAfter)
|
||||
->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null))
|
||||
->mapWithKeys(fn (array $check): array => [
|
||||
(string) $check['key'] => [
|
||||
'status' => $check['status'] ?? null,
|
||||
'blocking' => $check['blocking'] ?? null,
|
||||
'severity' => $check['severity'] ?? null,
|
||||
'reason_code' => $check['reason_code'] ?? null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
|
||||
expect($checksAfterByKey)->toBe($checksBeforeByKey);
|
||||
|
||||
$fingerprintAfter = VerificationReportFingerprint::forReport($reportAfter);
|
||||
expect($fingerprintAfter)->toBe($fingerprintBefore);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('action', AuditActionId::VerificationCheckAcknowledged->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
expect($audit?->metadata)->toMatchArray([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'report_id' => (int) $run->getKey(),
|
||||
'flow' => 'provider.connection.check',
|
||||
'check_key' => 'provider_connection.token_acquisition',
|
||||
'reason_code' => 'authentication_failed',
|
||||
]);
|
||||
|
||||
$metadata = $audit?->metadata ?? [];
|
||||
|
||||
expect($metadata)->not->toHaveKey('ack_reason');
|
||||
expect(json_encode($metadata))->not->toContain('Known issue');
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
|
||||
it('computes the same fingerprint regardless of check ordering', function (): void {
|
||||
$checksA = [
|
||||
['key' => 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'],
|
||||
['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'],
|
||||
];
|
||||
|
||||
$checksB = [
|
||||
['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'],
|
||||
['key' => 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'],
|
||||
];
|
||||
|
||||
expect(VerificationReportFingerprint::forChecks($checksA))
|
||||
->toBe(VerificationReportFingerprint::forChecks($checksB));
|
||||
});
|
||||
|
||||
it('treats missing severity as empty string for fingerprint determinism', function (): void {
|
||||
$withMissingSeverity = [
|
||||
['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied'],
|
||||
];
|
||||
|
||||
$withEmptySeverity = [
|
||||
['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied', 'severity' => ''],
|
||||
];
|
||||
|
||||
expect(VerificationReportFingerprint::forChecks($withMissingSeverity))
|
||||
->toBe(VerificationReportFingerprint::forChecks($withEmptySeverity));
|
||||
});
|
||||
|
||||
it('treats severity-only changes as different fingerprints (missing != info)', function (): void {
|
||||
$missingSeverity = [
|
||||
['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error'],
|
||||
];
|
||||
|
||||
$infoSeverity = [
|
||||
['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error', 'severity' => 'info'],
|
||||
];
|
||||
|
||||
expect(VerificationReportFingerprint::forChecks($missingSeverity))
|
||||
->not->toBe(VerificationReportFingerprint::forChecks($infoSeverity));
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Verification\VerificationReportFingerprint;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -21,12 +22,9 @@
|
||||
JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
$report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi'];
|
||||
$report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret'];
|
||||
$report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret';
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
$previousRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
@ -36,9 +34,30 @@
|
||||
],
|
||||
]);
|
||||
|
||||
assertNoOutboundHttp(function () use ($run): void {
|
||||
$report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi'];
|
||||
$report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret'];
|
||||
$report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret';
|
||||
$report['previous_report_id'] = (int) $previousRun->getKey();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
$fingerprint = VerificationReportFingerprint::forReport($report);
|
||||
|
||||
assertNoOutboundHttp(function () use ($run, $fingerprint): void {
|
||||
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
||||
->assertSee('Verification report')
|
||||
->assertSee('Open previous verification')
|
||||
->assertSee($fingerprint)
|
||||
->assertSee('Token acquisition works')
|
||||
->assertDontSee('access_token')
|
||||
->assertDontSee('Bearer abc.def.ghi')
|
||||
|
||||
@ -4,8 +4,17 @@
|
||||
|
||||
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the verification report viewer DB-only (no outbound HTTP, no job dispatch)', function (): void {
|
||||
@ -48,3 +57,79 @@
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('renders onboarding verify surfaces DB-only (no outbound HTTP, no job/queue dispatch)', function (): void {
|
||||
Bus::fake();
|
||||
Queue::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$report = VerificationReportWriter::build('provider.connection.check', [
|
||||
[
|
||||
'key' => 'onboarding_check',
|
||||
'title' => 'Onboarding check',
|
||||
'status' => 'fail',
|
||||
'severity' => 'high',
|
||||
'blocking' => false,
|
||||
'reason_code' => 'missing_configuration',
|
||||
'message' => 'Setup missing.',
|
||||
'evidence' => [],
|
||||
'next_steps' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_report' => $report,
|
||||
],
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'current_step' => 'verify',
|
||||
'state' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'verification_operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
'started_by_user_id' => (int) $user->getKey(),
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
assertNoOutboundHttp(function () use ($user): void {
|
||||
$this->actingAs($user)
|
||||
->get('/admin/onboarding')
|
||||
->assertSuccessful()
|
||||
->assertSee('Onboarding check');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
20
tests/Unit/AuditContextSanitizerTest.php
Normal file
20
tests/Unit/AuditContextSanitizerTest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Audit\AuditContextSanitizer;
|
||||
|
||||
it('does not redact dot-separated flow identifiers', function (): void {
|
||||
expect(AuditContextSanitizer::sanitize('provider.connection.check'))
|
||||
->toBe('provider.connection.check');
|
||||
});
|
||||
|
||||
it('redacts jwt-like strings', function (): void {
|
||||
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
|
||||
.'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.'
|
||||
.'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||
|
||||
expect(AuditContextSanitizer::sanitize($jwt))
|
||||
->toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user