Compare commits

...

1 Commits

Author SHA1 Message Date
439248ba15 feat: verification report framework (074) (#89)
Implements the 074 verification checklist framework.

Highlights:
- Versioned verification report contract stored in operation_runs.context.verification_report (DB-only viewer).
- Strict sanitizer/redaction (evidence pointers only; no tokens/headers/payloads) + schema validation.
- Centralized BADGE-001 semantics for check status, severity, and overall report outcome.
- Deterministic start (dedupe while active) via shared StartVerification service; capability-first authorization (non-member 404, member missing capability 403).
- Completion audit event (verification.completed) with redacted metadata.
- Integrations: OperationRun detail viewer, onboarding wizard verification step, provider connection start surfaces.

Tests:
- vendor/bin/sail artisan test --compact tests/Feature/Verification tests/Unit/Badges/VerificationBadgesTest.php
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #89
2026-02-03 23:58:17 +00:00
41 changed files with 3178 additions and 50 deletions

View File

@ -5,8 +5,8 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Support\VerificationReportViewer;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -15,15 +15,17 @@
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\TenantMembershipManager;
use App\Services\OperationRunService;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
@ -37,6 +39,7 @@
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
@ -236,14 +239,21 @@ public function content(Schema $schema): Schema
->schema([
Section::make('Verification')
->schema([
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
->badge()
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
View::make('filament.components.verification-report-viewer')
->viewData(fn (): array => [
'report' => $this->verificationReport(),
]),
SchemaActions::make([
UiEnforcement::forTableAction(
Action::make('wizardStartVerification')
->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
->action(fn () => $this->startVerification()),
fn (): ?Tenant => $this->managedTenant,
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
Action::make('wizardViewVerificationRun')
->label('View run')
->url(fn (): ?string => $this->verificationRunUrl())
@ -467,6 +477,37 @@ private function verificationRunUrl(): ?string
return OperationRunLinks::view($runId, $this->managedTenant);
}
/**
* @return array<string, mixed>|null
*/
private function verificationReport(): ?array
{
if (! $this->managedTenant instanceof Tenant) {
return null;
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
if (! is_int($runId)) {
return null;
}
$run = OperationRun::query()
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($runId)
->first();
if (! $run instanceof OperationRun) {
return null;
}
return VerificationReportViewer::report($run);
}
private function bootstrapRunsLabel(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
@ -819,6 +860,24 @@ public function startVerification(): void
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
$workspaceMembership = WorkspaceMembership::query()
->where('workspace_id', (int) $this->workspace->getKey())
->where('user_id', (int) $user->getKey())
->first();
$role = is_string($workspaceMembership?->role ?? null) ? (string) $workspaceMembership->role : 'readonly';
app(TenantMembershipManager::class)->addMember(
tenant: $tenant,
actor: $user,
member: $user,
role: $role,
source: 'manual',
sourceRef: 'managed_tenant_onboarding',
);
}
$connection = $this->resolveSelectedProviderConnection($tenant);
if (! $connection instanceof ProviderConnection) {
@ -831,18 +890,9 @@ public function startVerification(): void
return;
}
$result = app(ProviderOperationStartGate::class)->start(
$result = app(StartVerification::class)->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
},
initiator: $user,
extraContext: [
'wizard' => [

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
@ -136,12 +137,35 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Verification report')
->schema([
ViewEntry::make('verification_report')
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->context ?? [])
->state(function (OperationRun $record): array {
$context = $record->context ?? [];
$context = is_array($context) ? $context : [];
if (array_key_exists('verification_report', $context)) {
$context['verification_report'] = [
'redacted' => true,
'note' => 'Rendered in the Verification report section.',
];
}
return $context;
})
->columnSpanFull(),
])
->columnSpanFull(),

View File

@ -5,7 +5,6 @@
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -15,6 +14,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -175,29 +175,22 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
if (! $tenant instanceof Tenant) {
abort(404);
}
$initiator = $user;
if (! $user instanceof User) {
abort(403);
}
$result = $gate->start(
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
initiator: $user,
);
if ($result->status === 'scope_busy') {

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\ProviderConnectionResource;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -14,6 +13,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
@ -167,7 +167,7 @@ protected function getHeaderActions(): array
&& $user->canAccessTenant($tenant)
&& $record->status !== 'disabled';
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = Tenant::current();
$user = auth()->user();
@ -185,18 +185,9 @@ protected function getHeaderActions(): array
$initiator = $user;
$result = $gate->start(
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
final class VerificationReportViewer
{
/**
* @return array<string, mixed>|null
*/
public static function report(OperationRun $run): ?array
{
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
if (! is_array($report)) {
return null;
}
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
return null;
}
return $report;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];
if (array_key_exists('verification_report', $context)) {
return true;
}
return in_array((string) $run->type, ['provider.connection.check'], true);
}
}

View File

@ -7,11 +7,14 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\Contracts\HealthResult;
use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -83,17 +86,64 @@ public function handle(
$this->updateRunTargetScope($this->operationRun, $connection, $entraTenantName);
$report = VerificationReportWriter::write(
run: $this->operationRun,
checks: [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => $result->healthy ? 'pass' : 'fail',
'severity' => $result->healthy ? 'info' : 'critical',
'blocking' => ! $result->healthy,
'reason_code' => $result->healthy ? 'ok' : ($result->reasonCode ?? 'unknown_error'),
'message' => $result->healthy ? 'Connection is healthy.' : ($result->message ?? 'Health check failed.'),
'evidence' => array_values(array_filter([
[
'kind' => 'provider_connection_id',
'value' => (int) $connection->getKey(),
],
[
'kind' => 'entra_tenant_id',
'value' => (string) $connection->entra_tenant_id,
],
is_numeric($result->meta['http_status'] ?? null) ? [
'kind' => 'http_status',
'value' => (int) $result->meta['http_status'],
] : null,
is_string($result->meta['organization_id'] ?? null) ? [
'kind' => 'organization_id',
'value' => (string) $result->meta['organization_id'],
] : null,
])),
'next_steps' => $result->healthy
? []
: [[
'label' => 'Review provider connection',
'url' => \App\Filament\Resources\ProviderConnectionResource::getUrl('edit', [
'record' => (int) $connection->getKey(),
], tenant: $tenant),
]],
],
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => (string) $connection->entra_tenant_id,
],
);
if ($result->healthy) {
$runs->updateRun(
$run = $runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
return;
}
$runs->updateRun(
$run = $runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
@ -103,6 +153,8 @@ public function handle(
'message' => $result->message ?? 'Health check failed.',
]],
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
}
private function resolveEntraTenantName(ProviderConnection $connection, HealthResult $result): ?string
@ -145,4 +197,34 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
'last_error_message' => $result->healthy ? null : $result->message,
]);
}
/**
* @param array<string, mixed> $report
*/
private function logVerificationCompletion(Tenant $tenant, User $actor, OperationRun $run, array $report): void
{
$workspace = $tenant->workspace;
if (! $workspace) {
return;
}
$counts = $report['summary']['counts'] ?? [];
$counts = is_array($counts) ? $counts : [];
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::VerificationCompleted->value,
context: [
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
'counts' => $counts,
],
],
actor: $actor,
status: $run->outcome === OperationRunOutcome::Succeeded->value ? 'success' : 'failed',
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\Verification;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class StartVerification
{
public function __construct(
private readonly ProviderOperationStartGate $providers,
) {}
/**
* Start (or dedupe) a provider-connection verification run.
*
* @param array<string, mixed> $extraContext
*/
public function providerConnectionCheck(
Tenant $tenant,
ProviderConnection $connection,
User $initiator,
array $extraContext = [],
): ProviderOperationStartResult {
if (! $initiator->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
Gate::forUser($initiator)->authorize(Capabilities::PROVIDER_RUN, $tenant);
return $this->providers->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use ($tenant, $initiator, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
},
initiator: $initiator,
extraContext: $extraContext,
);
}
}

View File

@ -27,4 +27,6 @@ enum AuditActionId: string
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
case VerificationCompleted = 'verification.completed';
}

View File

@ -36,6 +36,9 @@ final class BadgeCatalog
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
];
/**

View File

@ -28,4 +28,7 @@ enum BadgeDomain: string
case RestoreResultStatus = 'restore_result_status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity';
case VerificationReportOverall = 'verification_report_overall';
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckSeverity;
final class VerificationCheckSeverityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckSeverity::Info->value => new BadgeSpec('Info', 'gray', 'heroicon-m-information-circle'),
VerificationCheckSeverity::Low->value => new BadgeSpec('Low', 'info', 'heroicon-m-arrow-down'),
VerificationCheckSeverity::Medium->value => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::High->value => new BadgeSpec('High', 'danger', 'heroicon-m-exclamation-triangle'),
VerificationCheckSeverity::Critical->value => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationCheckStatus;
final class VerificationCheckStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationCheckStatus::Pass->value => new BadgeSpec('Pass', 'success', 'heroicon-m-check-circle'),
VerificationCheckStatus::Fail->value => new BadgeSpec('Fail', 'danger', 'heroicon-m-x-circle'),
VerificationCheckStatus::Warn->value => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationCheckStatus::Skip->value => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
VerificationCheckStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Verification\VerificationReportOverall;
final class VerificationReportOverallBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
VerificationReportOverall::Ready->value => new BadgeSpec('Ready', 'success', 'heroicon-m-check-circle'),
VerificationReportOverall::NeedsAttention->value => new BadgeSpec('Needs attention', 'warning', 'heroicon-m-exclamation-triangle'),
VerificationReportOverall::Blocked->value => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'),
VerificationReportOverall::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckSeverity: string
{
case Info = 'info';
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case Critical = 'critical';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Support\Verification;
enum VerificationCheckStatus: string
{
case Pass = 'pass';
case Fail = 'fail';
case Warn = 'warn';
case Skip = 'skip';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Support\Verification;
enum VerificationReportOverall: string
{
case Ready = 'ready';
case NeedsAttention = 'needs_attention';
case Blocked = 'blocked';
case Running = 'running';
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,358 @@
<?php
namespace App\Support\Verification;
final class VerificationReportSanitizer
{
/**
* @var array<int, string>
*/
private const FORBIDDEN_KEY_SUBSTRINGS = [
'access_token',
'refresh_token',
'client_secret',
'authorization',
'password',
'cookie',
'set-cookie',
];
/**
* @return array<string, mixed>
*/
public static function sanitizeReport(array $report): array
{
$sanitized = [];
$schemaVersion = self::sanitizeShortString($report['schema_version'] ?? null, fallback: null);
if ($schemaVersion !== null) {
$sanitized['schema_version'] = $schemaVersion;
}
$flow = self::sanitizeShortString($report['flow'] ?? null, fallback: null);
if ($flow !== null) {
$sanitized['flow'] = $flow;
}
$generatedAt = self::sanitizeShortString($report['generated_at'] ?? null, fallback: null);
if ($generatedAt !== null) {
$sanitized['generated_at'] = $generatedAt;
}
if (is_array($report['identity'] ?? null)) {
$identity = self::sanitizeIdentity((array) $report['identity']);
if ($identity !== []) {
$sanitized['identity'] = $identity;
}
}
$summary = is_array($report['summary'] ?? null) ? (array) $report['summary'] : [];
$summary = self::sanitizeSummary($summary);
if ($summary !== null) {
$sanitized['summary'] = $summary;
}
$checks = is_array($report['checks'] ?? null) ? (array) $report['checks'] : [];
$checks = self::sanitizeChecks($checks);
if ($checks !== null) {
$sanitized['checks'] = $checks;
}
return $sanitized;
}
/**
* @param array<string, mixed> $identity
* @return array<string, int|string>
*/
private static function sanitizeIdentity(array $identity): array
{
$sanitized = [];
foreach ($identity as $key => $value) {
if (! is_string($key) || trim($key) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($key)) {
continue;
}
if (is_int($value)) {
$sanitized[$key] = $value;
continue;
}
if (! is_string($value)) {
continue;
}
$value = self::sanitizeValueString($value);
if ($value !== null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* @param array<string, mixed> $summary
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null
*/
private static function sanitizeSummary(array $summary): ?array
{
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return null;
}
$counts = is_array($summary['counts'] ?? null) ? (array) $summary['counts'] : [];
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
return null;
}
}
return [
'overall' => $overall,
'counts' => [
'total' => $counts['total'],
'pass' => $counts['pass'],
'fail' => $counts['fail'],
'warn' => $counts['warn'],
'skip' => $counts['skip'],
'running' => $counts['running'],
],
];
}
/**
* @param array<int, mixed> $checks
* @return array<int, array<string, mixed>>|null
*/
private static function sanitizeChecks(array $checks): ?array
{
if ($checks === []) {
return [];
}
$sanitized = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$key = self::sanitizeShortString($check['key'] ?? null, fallback: null);
$title = self::sanitizeShortString($check['title'] ?? null, fallback: null);
$reasonCode = self::sanitizeShortString($check['reason_code'] ?? null, fallback: null);
if ($key === null || $title === null || $reasonCode === null) {
continue;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
continue;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
continue;
}
$messageRaw = $check['message'] ?? null;
if (! is_string($messageRaw) || trim($messageRaw) === '') {
continue;
}
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
$sanitized[] = [
'key' => $key,
'title' => $title,
'status' => $status,
'severity' => $severity,
'blocking' => $blocking,
'reason_code' => $reasonCode,
'message' => self::sanitizeMessage($messageRaw),
'evidence' => self::sanitizeEvidence(is_array($check['evidence'] ?? null) ? (array) $check['evidence'] : []),
'next_steps' => self::sanitizeNextSteps(is_array($check['next_steps'] ?? null) ? (array) $check['next_steps'] : []),
];
}
return $sanitized;
}
/**
* @param array<int, mixed> $evidence
* @return array<int, array{kind: string, value: int|string}>
*/
private static function sanitizeEvidence(array $evidence): array
{
$sanitized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = $pointer['kind'] ?? null;
if (! is_string($kind) || trim($kind) === '') {
continue;
}
if (self::containsForbiddenKeySubstring($kind)) {
continue;
}
$value = $pointer['value'] ?? null;
if (is_int($value)) {
$sanitized[] = ['kind' => trim($kind), 'value' => $value];
continue;
}
if (! is_string($value)) {
continue;
}
$sanitizedValue = self::sanitizeValueString($value);
if ($sanitizedValue === null) {
continue;
}
$sanitized[] = ['kind' => trim($kind), 'value' => $sanitizedValue];
}
return $sanitized;
}
/**
* @param array<int, mixed> $nextSteps
* @return array<int, array{label: string, url: string}>
*/
private static function sanitizeNextSteps(array $nextSteps): array
{
$sanitized = [];
foreach ($nextSteps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$sanitized[] = [
'label' => $label,
'url' => $url,
];
}
return $sanitized;
}
private static function sanitizeMessage(mixed $message): string
{
if (! is_string($message)) {
return '—';
}
$message = trim(str_replace(["\r", "\n"], ' ', $message));
$message = preg_replace('/\bAuthorization\s*:\s*[^\s]+(?:\s+[^\s]+)?/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', '[REDACTED_AUTH]', $message) ?? $message;
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\b\s*[:=]\s*[^\s,;]+/i', '[REDACTED_SECRET]', $message) ?? $message;
$message = preg_replace('/"(access_token|refresh_token|client_secret|password)"\s*:\s*"[^"]*"/i', '"[REDACTED]":"[REDACTED]"', $message) ?? $message;
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
$message = str_ireplace(
['client_secret', 'access_token', 'refresh_token', 'authorization', 'bearer '],
'[REDACTED]',
$message,
);
$message = trim($message);
return $message === '' ? '—' : substr($message, 0, 240);
}
private static function sanitizeShortString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
if (self::containsForbiddenKeySubstring($value)) {
return $fallback;
}
return substr($value, 0, 200);
}
private static function sanitizeValueString(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', $value)) {
return null;
}
if (strlen($value) > 512) {
return null;
}
if (preg_match('/\b[A-Za-z0-9\-\._~\+\/]{128,}\b/', $value)) {
return null;
}
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return null;
}
}
return $value;
}
private static function containsForbiddenKeySubstring(string $value): bool
{
$lower = strtolower($value);
foreach (self::FORBIDDEN_KEY_SUBSTRINGS as $needle) {
if (str_contains($lower, $needle)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,235 @@
<?php
namespace App\Support\Verification;
use DateTimeImmutable;
final class VerificationReportSchema
{
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
/**
* @return array<string, mixed>|null
*/
public static function normalizeReport(mixed $report): ?array
{
if (! is_array($report)) {
return null;
}
if (! self::isValidReport($report)) {
return null;
}
return $report;
}
/**
* @param array<string, mixed> $report
*/
public static function isValidReport(array $report): bool
{
$schemaVersion = self::schemaVersion($report);
if ($schemaVersion === null || ! self::isSupportedSchemaVersion($schemaVersion)) {
return false;
}
if (! self::isNonEmptyString($report['flow'] ?? null)) {
return false;
}
if (! self::isIsoDateTimeString($report['generated_at'] ?? null)) {
return false;
}
if (array_key_exists('identity', $report) && ! is_array($report['identity'])) {
return false;
}
$summary = $report['summary'] ?? null;
if (! is_array($summary)) {
return false;
}
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return false;
}
$counts = $summary['counts'] ?? null;
if (! is_array($counts)) {
return false;
}
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! self::isNonNegativeInt($counts[$key] ?? null)) {
return false;
}
}
$checks = $report['checks'] ?? null;
if (! is_array($checks)) {
return false;
}
foreach ($checks as $check) {
if (! is_array($check) || ! self::isValidCheckResult($check)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $report
*/
public static function schemaVersion(array $report): ?string
{
$candidate = $report['schema_version'] ?? null;
if (! is_string($candidate)) {
return null;
}
$candidate = trim($candidate);
if ($candidate === '') {
return null;
}
if (! preg_match('/^\d+\.\d+\.\d+$/', $candidate)) {
return null;
}
return $candidate;
}
public static function isSupportedSchemaVersion(string $schemaVersion): bool
{
$parts = explode('.', $schemaVersion, 3);
if (count($parts) !== 3) {
return false;
}
$major = (int) $parts[0];
return $major === 1;
}
/**
* @param array<string, mixed> $check
*/
private static function isValidCheckResult(array $check): bool
{
if (! self::isNonEmptyString($check['key'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['title'] ?? null)) {
return false;
}
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, VerificationCheckStatus::values(), true)) {
return false;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
return false;
}
if (! is_bool($check['blocking'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['reason_code'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($check['message'] ?? null)) {
return false;
}
$evidence = $check['evidence'] ?? null;
if (! is_array($evidence)) {
return false;
}
foreach ($evidence as $pointer) {
if (! is_array($pointer) || ! self::isValidEvidencePointer($pointer)) {
return false;
}
}
$nextSteps = $check['next_steps'] ?? null;
if (! is_array($nextSteps)) {
return false;
}
foreach ($nextSteps as $step) {
if (! is_array($step) || ! self::isValidNextStep($step)) {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $pointer
*/
private static function isValidEvidencePointer(array $pointer): bool
{
if (! self::isNonEmptyString($pointer['kind'] ?? null)) {
return false;
}
$value = $pointer['value'] ?? null;
return is_int($value) || self::isNonEmptyString($value);
}
/**
* @param array<string, mixed> $step
*/
private static function isValidNextStep(array $step): bool
{
if (! self::isNonEmptyString($step['label'] ?? null)) {
return false;
}
if (! self::isNonEmptyString($step['url'] ?? null)) {
return false;
}
return true;
}
private static function isNonEmptyString(mixed $value): bool
{
return is_string($value) && trim($value) !== '';
}
private static function isNonNegativeInt(mixed $value): bool
{
return is_int($value) && $value >= 0;
}
private static function isIsoDateTimeString(mixed $value): bool
{
if (! self::isNonEmptyString($value)) {
return false;
}
try {
new DateTimeImmutable((string) $value);
return true;
} catch (\Throwable) {
return false;
}
}
}

View File

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\OperationRun;
final class VerificationReportWriter
{
/**
* Baseline reason code taxonomy (v1).
*
* @var array<int, string>
*/
private const array BASELINE_REASON_CODES = [
'ok',
'not_applicable',
'missing_configuration',
'permission_denied',
'authentication_failed',
'throttled',
'dependency_unreachable',
'invalid_state',
'unknown_error',
];
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function write(OperationRun $run, array $checks, array $identity = []): array
{
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
$report = self::build($flow, $checks, $identity);
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
}
$context = is_array($run->context) ? $run->context : [];
$context['verification_report'] = $report;
$run->update(['context' => $context]);
return $report;
}
/**
* @param array<int, array<string, mixed>> $checks
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
public static function build(string $flow, array $checks, array $identity = []): array
{
$flow = trim($flow);
$flow = $flow !== '' ? $flow : 'unknown';
$normalizedChecks = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$normalizedChecks[] = self::normalizeCheckResult($check);
}
$counts = self::deriveCounts($normalizedChecks);
$report = [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow,
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => self::deriveOverall($normalizedChecks, $counts),
'counts' => $counts,
],
'checks' => $normalizedChecks,
];
if ($identity !== []) {
$report['identity'] = $identity;
}
return $report;
}
/**
* @return array<string, mixed>
*/
private static function buildFallbackReport(string $flow): array
{
return [
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow !== '' ? $flow : 'unknown',
'generated_at' => now()->toISOString(),
'summary' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
'total' => 0,
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [],
];
}
/**
* @param array<string, mixed> $check
* @return array{
* key: string,
* title: string,
* status: string,
* severity: string,
* blocking: bool,
* reason_code: string,
* message: string,
* evidence: array<int, array{kind: string, value: int|string}>,
* next_steps: array<int, array{label: string, url: string}>
* }
*/
private static function normalizeCheckResult(array $check): array
{
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
return [
'key' => $key,
'title' => $title,
'status' => self::normalizeCheckStatus($check['status'] ?? null),
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
];
}
private static function normalizeCheckStatus(mixed $status): string
{
if (! is_string($status)) {
return VerificationCheckStatus::Fail->value;
}
$status = strtolower(trim($status));
return in_array($status, VerificationCheckStatus::values(), true)
? $status
: VerificationCheckStatus::Fail->value;
}
private static function normalizeCheckSeverity(mixed $severity): string
{
if (! is_string($severity)) {
return VerificationCheckSeverity::Info->value;
}
$severity = strtolower(trim($severity));
return in_array($severity, VerificationCheckSeverity::values(), true)
? $severity
: VerificationCheckSeverity::Info->value;
}
private static function normalizeReasonCode(mixed $reasonCode): string
{
if (! is_string($reasonCode)) {
return 'unknown_error';
}
$reasonCode = strtolower(trim($reasonCode));
if ($reasonCode === '') {
return 'unknown_error';
}
if (str_starts_with($reasonCode, 'ext.')) {
return $reasonCode;
}
$reasonCode = match ($reasonCode) {
'graph_throttled' => 'throttled',
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
'provider_auth_failed' => 'authentication_failed',
'validation_error', 'conflict_detected' => 'invalid_state',
'unknown' => 'unknown_error',
default => $reasonCode,
};
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
}
/**
* @return array<int, array{kind: string, value: int|string}>
*/
private static function normalizeEvidence(mixed $evidence): array
{
if (! is_array($evidence)) {
return [];
}
$normalized = [];
foreach ($evidence as $pointer) {
if (! is_array($pointer)) {
continue;
}
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
$value = $pointer['value'] ?? null;
if ($kind === null) {
continue;
}
if (! is_int($value) && ! is_string($value)) {
continue;
}
if (is_string($value) && trim($value) === '') {
continue;
}
$normalized[] = [
'kind' => $kind,
'value' => is_int($value) ? $value : trim($value),
];
}
return $normalized;
}
/**
* @return array<int, array{label: string, url: string}>
*/
private static function normalizeNextSteps(mixed $steps): array
{
if (! is_array($steps)) {
return [];
}
$normalized = [];
foreach ($steps as $step) {
if (! is_array($step)) {
continue;
}
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
if ($label === null || $url === null) {
continue;
}
$normalized[] = [
'label' => $label,
'url' => $url,
];
}
return $normalized;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
*/
private static function deriveCounts(array $checks): array
{
$counts = [
'total' => count($checks),
'pass' => 0,
'fail' => 0,
'warn' => 0,
'skip' => 0,
'running' => 0,
];
foreach ($checks as $check) {
$status = $check['status'] ?? null;
if (! is_string($status) || ! array_key_exists($status, $counts)) {
continue;
}
$counts[$status] += 1;
}
return $counts;
}
/**
* @param array<int, array{status: string, blocking: bool}> $checks
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
*/
private static function deriveOverall(array $checks, array $counts): string
{
if (($counts['running'] ?? 0) > 0) {
return VerificationReportOverall::Running->value;
}
if (($counts['total'] ?? 0) === 0) {
return VerificationReportOverall::NeedsAttention->value;
}
foreach ($checks as $check) {
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
return VerificationReportOverall::Blocked->value;
}
}
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
return VerificationReportOverall::NeedsAttention->value;
}
return VerificationReportOverall::Ready->value;
}
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
{
if (! is_string($value)) {
return $fallback;
}
$value = trim($value);
if ($value === '') {
return $fallback;
}
return $value;
}
}

View File

@ -0,0 +1,178 @@
@php
$report = isset($getState) ? $getState() : ($report ?? null);
$report = is_array($report) ? $report : null;
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = $summary['counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
@endphp
<div class="space-y-4">
@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 doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
</div>
</div>
@else
@php
$overallSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
<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>
@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)
@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 : [];
@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">
{{ $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
</div>
@endif
@endif
</div>

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Verification Checklist Framework (Enterprise-Ready)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-03
**Feature**: [specs/074-verification-checklist/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
- Validation pass (2026-02-03): Spec avoids framework specifics and focuses on contract + UX outcomes. Next step is planning to translate these requirements into a minimal set of deliverables (report schema, viewer, authorization semantics, audit events, and adoption points).

View File

@ -0,0 +1,47 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"identity": {
"provider_connection_id": 123
},
"summary": {
"overall": "blocked",
"counts": {
"total": 2,
"pass": 1,
"fail": 1,
"warn": 0,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.token_acquisition",
"title": "Token acquisition works",
"status": "fail",
"severity": "high",
"blocking": true,
"reason_code": "authentication_failed",
"message": "The app cannot acquire a token with the configured credentials.",
"evidence": [
{ "kind": "provider_connection_id", "value": 123 }
],
"next_steps": [
{ "label": "Review connection credentials", "url": "/admin/provider-connections/123/edit" }
]
},
{
"key": "provider_connection.permissions",
"title": "Required permissions are granted",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The configured app permissions meet the required baseline.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,29 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "ready",
"counts": {
"total": 1,
"pass": 1,
"fail": 0,
"warn": 0,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,51 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "running",
"counts": {
"total": 3,
"pass": 1,
"fail": 0,
"warn": 0,
"skip": 0,
"running": 2
}
},
"checks": [
{
"key": "provider_connection.token_acquisition",
"title": "Token acquisition works",
"status": "running",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "Check is currently running.",
"evidence": [],
"next_steps": []
},
{
"key": "provider_connection.permissions",
"title": "Required permissions are granted",
"status": "running",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "Check is currently running.",
"evidence": [],
"next_steps": []
},
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,42 @@
{
"schema_version": "1.0.0",
"flow": "provider.connection.check",
"generated_at": "2026-02-03T22:00:00Z",
"summary": {
"overall": "needs_attention",
"counts": {
"total": 2,
"pass": 1,
"fail": 0,
"warn": 1,
"skip": 0,
"running": 0
}
},
"checks": [
{
"key": "provider_connection.optional_metadata",
"title": "Optional metadata is present",
"status": "warn",
"severity": "medium",
"blocking": false,
"reason_code": "missing_configuration",
"message": "Some optional metadata is missing; this may reduce diagnostics quality.",
"evidence": [],
"next_steps": [
{ "label": "Open provider connection settings", "url": "/admin/provider-connections" }
]
},
{
"key": "provider_connection.health",
"title": "Provider connection is healthy",
"status": "pass",
"severity": "info",
"blocking": false,
"reason_code": "ok",
"message": "The provider connection passed all required health checks.",
"evidence": [],
"next_steps": []
}
]
}

View File

@ -0,0 +1,26 @@
# Reason Codes (074)
This file defines the baseline `reason_code` taxonomy for verification check results.
## Rules
- Reason codes are **stable** and **machine-readable**.
- New codes must be appended (avoid renames) to keep support and automation stable.
- Flow/check-specific codes must use the reserved namespace: `ext.*`.
## Baseline Codes (v1)
- `ok` — Check passed.
- `not_applicable` — Check skipped because it doesnt apply to this identity/scope.
- `missing_configuration` — Required config is absent.
- `permission_denied` — Insufficient permissions / consent missing.
- `authentication_failed` — Token acquisition or auth precondition failed.
- `throttled` — Remote dependency throttled (e.g., 429/503) and check could not complete.
- `dependency_unreachable` — Remote dependency unavailable.
- `invalid_state` — Local model state conflicts with required preconditions.
- `unknown_error` — Failure could not be classified.
## Reserved Extension Namespace
- `ext.<flow>.<detail>` — Flow-specific extensions.
- Example: `ext.managed_tenant_onboarding.role_mapping_missing`

View File

@ -0,0 +1,128 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-report.schema.json",
"title": "VerificationReport",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"flow",
"generated_at",
"summary",
"checks"
],
"properties": {
"schema_version": {
"type": "string",
"description": "Version of the verification report schema (SemVer)."
},
"flow": {
"type": "string",
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"identity": {
"type": "object",
"description": "Scope identifiers for what is being verified.",
"additionalProperties": true
},
"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": {
"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"}
}
}
}
}

View File

@ -0,0 +1,61 @@
# Data Model: Verification Checklist Framework (074)
## Overview
This feature introduces a *versioned verification report document* attached to an existing `OperationRun`.
No new database tables are required for v1.
## Existing Entities Used
### OperationRun (`operation_runs`)
Selected fields:
- `id`
- `tenant_id`
- `user_id`
- `type` (used as the verification flow identifier)
- `status` (`queued` | `running` | `completed`)
- `outcome` (`pending` | `succeeded` | `failed`)
- `summary_counts` (JSONB)
- `failure_summary` (JSONB)
- `context` (JSONB)
- `started_at`, `completed_at`
Idempotency:
- DB-enforced dedupe for active runs via partial unique index on `(tenant_id, run_identity_hash)` where `status IN ('queued','running')`.
## New Logical Data (stored inside OperationRun context)
### VerificationReport (`operation_runs.context.verification_report`)
- Stored as JSON in `context` under `verification_report`.
- Versioned by `schema_version`.
- Rendered DB-only (no external calls during view).
High-level shape (see `contracts/verification-report.schema.json` for the canonical contract):
- `schema_version`
- `flow` (identifier; for v1 this can align with `operation_runs.type`)
- `identity` (scope identifiers such as `tenant_id`, `provider_connection_id`, etc.)
- `generated_at`
- `summary` (counts, overall state)
- `checks[]` (check results)
### CheckResult (within `checks[]`)
- `key`, `title`
- `status`: `pass|fail|warn|skip|running`
- `severity`: `info|low|medium|high|critical`
- `blocking`: boolean
- `reason_code`
- `message`
- `evidence[]`: safe pointers only
- `next_steps[]`: links only in v1
## Audit
Verification start and completion are recorded in `audit_logs` using stable `action` identifiers (via `App\Support\Audit\AuditActionId`). Metadata is minimal and sanitized.
## Notes / Constraints
- Viewer must be DB-only: rendering the report must not dispatch jobs or perform HTTP.
- Evidence must be redacted/safe: no secrets/tokens/payload dumps in stored or rendered report.

View File

@ -0,0 +1,127 @@
# Implementation Plan: Verification Checklist Framework (Enterprise-Ready)
**Branch**: `074-verification-checklist` | **Date**: 2026-02-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/074-verification-checklist/spec.md`
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
## Summary
- Introduce a versioned “verification report” contract that can be attached to an existing `OperationRun` and rendered consistently across multiple flows.
- Provide a reusable, DB-only report viewer (no outbound calls during render/hydration/poll) that presents summary + per-check statuses + safe evidence pointers + navigation-only next steps.
- Enforce enterprise semantics: stable reason codes, strict evidence redaction, deterministic active-run dedupe, and capability-first authorization aligned with RBAC-UX (non-members 404; members missing start capability 403).
- Emit audit events for verification start + completion using stable action identifiers with redacted metadata.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`)
**Testing**: Pest (PHPUnit)
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
**Project Type**: web
**Performance Goals**: Verification viewer renders fast from DB-only JSON (typical report ≤ 50 checks)
**Constraints**: Viewer is read-only and must not trigger any outbound HTTP or job dispatch; evidence must not contain secrets/tokens/payloads; new status-like badges must use centralized BADGE-001 mapping.
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding + provider ops + future readiness flows
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first, snapshots-second: PASS (this feature is run/report UX; no inventory semantics changed).
- Read/write separation: PASS (viewer is read-only; start surfaces enqueue-only and already follow `OperationRun` patterns).
- Graph contract path: PASS (viewer performs no Graph calls; verification execution remains in queued jobs that already follow provider gateway patterns).
- Deterministic capabilities: PASS (start/view gates reference the existing capability registry; no role-string checks).
- RBAC-UX: PASS (non-member tenant access is 404; member-but-missing-capability is 403; server-side gates enforce mutations/starts).
- Run observability: PASS (verification is represented as `OperationRun`; active-run dedupe enforced by the existing partial unique index on `(tenant_id, run_identity_hash)` for active statuses).
- Data minimization: PASS (report evidence constrained to safe pointers; audit metadata redacted; no secrets in stored report).
- Badge semantics (BADGE-001): PASS (plan includes adding a centralized badge domain for check statuses/severity; no ad-hoc UI mappings).
## Project Structure
### Documentation (this feature)
```text
specs/074-verification-checklist/
├── 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)
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
config/
database/
resources/
routes/
tests/
```
**Structure Decision**: Single Laravel web application with Filament admin panel. The framework is implemented as:
- contract + helpers under `app/Support/**`
- report writer invoked from queued jobs under `app/Jobs/**` / `app/Services/**`
- viewer UI as Filament schema components and Blade views under `app/Filament/**` and `resources/views/filament/**`
- authorization via existing capabilities/gates/policies
## Complexity Tracking
No constitution violations required for this feature.
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Goals:
- Confirm the canonical storage location for the report (DB-only render) using existing `operation_runs.context` JSONB.
- Confirm active-run dedupe behavior and ensure it matches the specs “dedupe while active only” requirement.
- Confirm the correct approach for status-like UI badges in Filament (BADGE-001), so the viewer doesnt introduce ad-hoc mappings.
- Confirm the existing audit logger + redaction utilities and define stable action IDs for verification completion.
## 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 schema: versioned JSON document (checks + counts + timestamps + next steps) stored in `operation_runs.context.verification_report`.
- Reason codes: baseline set + reserved `ext.*` namespace.
- Evidence redaction: strict sanitizer so reports never store or render secrets/tokens/payloads.
- Viewer: reusable Filament view entry / component that renders summary + per-check details without any outbound calls.
- Authorization: view allowed for tenant-scoped members; start requires capability; non-member access is deny-as-not-found.
- Auditing: start + completion events logged with minimal redacted metadata.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
- Contract: create a canonical report schema (JSON Schema + example) and a small baseline reason-code list.
- Writer: add a `VerificationReportWriter` (or equivalent) that normalizes check results, enforces redaction rules, and writes the report into `OperationRun->context`.
- Viewer: add a reusable Filament UI renderer (Blade view + helper) that displays summary, counts, statuses, and next steps (links-only).
- Integration points:
- Show the verification report section in Monitoring → Operations run detail when present.
- Embed the same viewer in onboarding and provider connection verification flows.
- RBAC & UX:
- Enforce “view vs start” split (view allowed for tenant members; start capability required).
- Preserve RBAC-UX semantics (non-members 404; members missing capability 403).
- Audit:
- Keep existing start audit event; add a completion audit event emitted when the verification run finalizes.
- Tests (Pest):
- Viewer is DB-only (Http::fake + render assertion).
- Evidence redaction rules (report contains no forbidden keys/values).
- Dedupe semantics reuse active run (leveraging the existing partial unique index behavior).
## Constitution Check (Post-Design)
Re-check result: PASS. Design artifacts keep verification viewing DB-only, align with run observability + dedupe, enforce RBAC-UX semantics, and centralize status badge mappings.

View File

@ -0,0 +1,79 @@
# Quickstart: Verification Checklist Framework (074)
This quickstart explains how to *write* and *render* a verification report attached to an `OperationRun`.
## 1) Writing a report (queued job / service)
**Goal**: produce a `verification_report` JSON document and store it in `OperationRun->context`.
Guidelines:
- Generate reports inside queued execution (not in a Filament page render).
- Keep evidence pointer-only (IDs/masked/hashes), never raw payloads or tokens.
- Keep next steps navigation-only in v1.
Pseudo-code sketch:
```php
$context = is_array($run->context) ? $run->context : [];
$context['verification_report'] = [
'schema_version' => '1.0',
'flow' => $run->type,
'generated_at' => now('UTC')->toIso8601String(),
'identity' => [
'tenant_id' => (int) $run->tenant_id,
'provider_connection_id' => (int) data_get($run->context, 'provider_connection_id', 0),
],
'summary' => [
'overall' => 'needs_attention',
'counts' => [
'total' => 5,
'pass' => 3,
'fail' => 2,
'warn' => 0,
'skip' => 0,
'running' => 0,
],
],
'checks' => [
[
'key' => 'provider_connection.token_acquisition',
'title' => 'Token acquisition works',
'status' => 'fail',
'severity' => 'high',
'blocking' => true,
'reason_code' => 'permission_denied',
'message' => 'The app cannot acquire a token with the configured credentials.',
'evidence' => [
['kind' => 'provider_connection_id', 'value' => (int) data_get($run->context, 'provider_connection_id')],
],
'next_steps' => [
['label' => 'Review connection credentials', 'url' => '/admin/...'],
['label' => 'Microsoft docs: app permissions', 'url' => 'https://learn.microsoft.com/...'],
],
],
],
];
$run->update(['context' => $context]);
```
## 2) Rendering the report (Filament, DB-only)
Recommended integration points:
- Monitoring → Operations: in the `OperationRun` view page, show a “Verification report” section when `context.verification_report` exists.
- Flow pages (e.g., onboarding wizard): embed the same viewer component using the run ID stored in wizard state.
**Hard requirement**: rendering must not trigger any outbound HTTP (no Graph calls, no jobs dispatched, no side effects).
## 3) Authorization split
- Viewing a report: allowed for tenant-scoped members.
- Starting verification: requires a specific capability.
- Non-members: deny-as-not-found (404) for tenant-scoped pages and actions.
## 4) Tests to add
- Viewer DB-only render test: `Http::fake()` + assert no requests during render.
- Evidence redaction test: report JSON contains none of `access_token`, `client_secret`, `Authorization`, bearer tokens, or raw payload dumps.
- Dedupe test: repeated starts while active reuse the same run.

View File

@ -0,0 +1,86 @@
# Research: Verification Checklist Framework (074)
**Date**: 2026-02-03
**Phase**: Phase 0 (Foundational Research)
**Status**: Complete
---
## Decisions
### D-001 — Canonical storage location for verification reports
**Decision**: Store the verification report in `operation_runs.context.verification_report` (JSONB).
**Rationale**:
- Monitoring pages must be DB-only at render time (constitution: Operations / Run Observability Standard).
- `OperationRun` is the canonical operational record; keeping the report attached avoids new tables/indexing for v1.
- The existing UI already renders `OperationRun.context` safely as JSON, so we can progressively enhance into a structured viewer.
**Alternatives considered**:
- Dedicated `verification_reports` table: rejected for v1 to keep adoption lightweight; can be introduced later if querying/indexing becomes necessary.
---
### D-002 — Idempotency / dedupe mechanism
**Decision**: Use the existing `OperationRunService::ensureRunWithIdentity()` mechanism and the DB partial unique index on `(tenant_id, run_identity_hash)` for active statuses (`queued`, `running`).
**Rationale**:
- This repo already enforces active-run dedupe at the DB level via `operation_runs_active_unique`.
- Matches the clarified spec policy: dedupe only while a run is active; completed runs allow a new run.
**Alternatives considered**:
- Application-only locks/dedupe: rejected as non-race-safe.
---
### D-003 — Flow identifier and identity scope
**Decision**: Treat `OperationRun.type` as the primary flow identifier for the verification run, and keep additional flow details (wizard step, etc.) in `context`.
**Rationale**:
- Existing operations already key UX semantics (labels, polling, related links) off `OperationRun.type`.
- Dedupe identity hashing already includes `type`, making flow part of the dedupe boundary.
**Alternatives considered**:
- Separate `flow_id` column: rejected for v1 (schema change not required).
---
### D-004 — Reason code taxonomy and extensions
**Decision**: Maintain a small baseline set of cross-cutting reason codes, and reserve `ext.*` for flow/check-specific extensions.
**Rationale**:
- Prevents brittle UI parsing and enables future automation.
- Keeps room for flow-specific details without polluting the baseline vocabulary.
**Alternatives considered**:
- Free-form codes everywhere: rejected due to support/automation cost.
---
### D-005 — Evidence policy (strict safe pointers)
**Decision**: Evidence fields in check results are *strictly* structured safe pointers only (IDs, masked strings, hashes). No payloads, tokens, claims, headers, or full error bodies.
**Rationale**:
- Aligns with constitution data-minimization and safe logging rules.
- Avoids accidentally persisting secrets inside run context.
**Alternatives considered**:
- Storing raw error payloads: rejected for security and compliance risk.
---
### D-006 — UI semantics for statuses and badges
**Decision**: Render status-like values (check status, severity) via centralized badge semantics (BADGE-001), not ad-hoc mappings in feature pages.
**Rationale**:
- Prevents drift in meaning/colors across the suite.
- Enables straightforward regression tests for new/changed status values.
**Alternatives considered**:
- Inline color mapping inside a Blade view: rejected (violates BADGE-001).

View File

@ -0,0 +1,186 @@
# Feature Specification: Verification Checklist Framework (Enterprise-Ready)
**Feature Branch**: `074-verification-checklist`
**Created**: 2026-02-03
**Status**: Draft
**Input**: User description: "Replace binary verification UX with a structured, reusable verification checklist attached to verification runs; DB-only viewing; enterprise semantics (reason codes, audit, idempotency, RBAC)."
## Clarifications
### Session 2026-02-03
- Q: What idempotency policy do we want for “Start verification”? → A: Dedupe only while a run is active (queued/running); once completed/failed, “Start verification” creates a new run.
- Q: Who should be allowed to view verification reports? → A: Any authenticated workspace member with access to the tenant scope may view reports; starting verification requires a separate capability.
- Q: What policy should we use for `reason_code` taxonomy? → A: Versioned central taxonomy with a small baseline set + reserved `ext.*` namespace for feature-specific extensions.
- Q: Whats the required evidence/redaction policy for `evidence` in check results? → A: Evidence is strictly structured safe pointers only (internal IDs, masked strings, hashes); never raw payloads, tokens, claims, headers, or full error bodies.
- Q: Should “Next steps” CTAs be links only, or can they trigger server-side actions? → A: Links only (navigation-only) in v1.
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Operator sees whats wrong (Priority: P1)
As a workspace member onboarding or operating a managed tenant, I can run “Verify access” and see a structured checklist that clearly shows which checks passed, which failed, and what to do next.
**Why this priority**: This is the primary value of verification: reduce ambiguity and enable fast, correct remediation.
**Independent Test**: Seed a verification run with a report containing mixed outcomes and confirm the viewer renders an accurate summary, per-check status, and next steps without making any external calls.
**Acceptance Scenarios**:
1. **Given** a completed verification run with 2 failed checks, **When** I open the verification report viewer, **Then** I see an overall summary (“Needs attention” or “Blocked”), counts, and the two failed checks with actionable next steps.
2. **Given** a verification run that is still in progress, **When** I open the viewer, **Then** I see a “Running” state and partial results (if available) without errors.
---
### User Story 2 - Deterministic starts (idempotency / dedupe) (Priority: P2)
As an operator, if I click “Start verification” multiple times for the same tenant + provider connection + flow, the system behaves deterministically: it does not start duplicate active runs and guides me to the already-running run.
**Why this priority**: Prevents confusing duplicates, reduces load, and makes support/debugging repeatable.
**Independent Test**: Attempt to start verification twice for the same identity and assert that only one active run exists and the UI returns a consistent “already running” outcome.
**Acceptance Scenarios**:
1. **Given** an active verification run exists for the same identity, **When** I click “Start verification”, **Then** no duplicate run is started and I am directed to view the active run/report.
2. **Given** no active verification run exists (including when the most recent run is completed or failed), **When** I click “Start verification”, **Then** a new run starts and I can view its report as it progresses.
---
### User Story 3 - Least-privilege and safe disclosure (Priority: P3)
As a workspace member with access to the tenant scope (including read-only), I can view verification reports but cannot start verification unless I have the start capability. As a non-member, I cannot discover that a tenant or report exists.
**Why this priority**: Verification data can leak operational posture; access must follow least-privilege and “deny-as-not-found” for non-members.
**Independent Test**: Validate both authorization paths: read-only can view but cannot start; non-member receives a not-found response for all tenant-scoped verification routes.
**Acceptance Scenarios**:
1. **Given** I am a workspace member without the “start verification” capability, **When** I open the verification page, **Then** I can view past reports but the “Start verification” action is disabled and cannot be executed.
2. **Given** I am not a member of the workspace/tenant scope, **When** I attempt to access the verification report route, **Then** I receive a not-found response with no identifying hints.
---
### Edge Cases
- Report missing or malformed (e.g., run exists but report is absent or partial) → viewer shows a safe “Report unavailable” state and guidance.
- Unknown check keys or unknown reason codes (newer schema written by a newer verifier) → viewer degrades gracefully, still showing status/message/next steps when present.
- Large reports (near upper bound, e.g., 50 checks) → viewer remains responsive and summary counts remain correct.
- A run transitions from running → complete while the user is viewing → the viewer refreshes safely or the user can re-open without inconsistent states.
- Evidence contains unexpected fields → redaction rules prevent sensitive values from being displayed.
## Out of Scope
- Introducing a separate monitoring/observability platform beyond the existing run tracking and audit log.
- Any workflow that requires client-side handling of secrets.
- A full overhaul of onboarding wizards beyond replacing/embedding verification status with the checklist viewer.
- Provider job orchestration redesign unrelated to running verification checks.
- Server-side actions triggered directly from the checklist viewer (v1 is navigation-only).
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any external provider calls, any write/change behavior,
or any long-running/background work, the spec MUST describe safety gates (preview/confirmation/audit), tenant isolation,
run observability (run identity, visibility, and outcomes), and tests. If security-relevant DB-only actions intentionally
skip run tracking, the spec MUST describe the audit log entries.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant-scoped admin area vs platform/system admin area),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require explicit user confirmation,
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
### Functional Requirements
- **FR-001 — Canonical verification report contract**: The system MUST generate a versioned “Verification Report” document for each verification run, including: schema version, flow identifier, identity/scope, generated timestamp, summary counts, and a list of check results.
- **FR-002 — Check result contract**: Each check result MUST include: stable key, title, status (pass/fail/warn/skip/running), severity (info/low/medium/high/critical), blocking flag, reason code, human-readable message, safe evidence pointers, and one or more “next steps” actions (where applicable).
Evidence MUST be strictly limited to structured safe pointers (internal IDs, masked strings, hashes) and MUST NOT contain raw payloads, tokens, claims, headers, or full error bodies.
Next steps in v1 MUST be navigation-only (links to internal pages or external documentation) and MUST NOT trigger server-side actions.
- **FR-003 — Stable reason code taxonomy**: The system MUST use stable, documented reason codes for failed/warned/skipped outcomes so that support, automation, and future UI changes remain consistent.
The taxonomy MUST include a small baseline set of cross-cutting codes and MUST reserve an `ext.*` namespace for flow-specific or check-specific extensions.
- **FR-004 — DB-only viewing**: Viewing a verification checklist MUST be read-only and MUST NOT trigger any external calls (e.g., no provider API calls, no HTTP calls, no background jobs started as a side effect of rendering).
- **FR-005 — Start verification creates a run**: Starting verification MUST create (or reuse, per dedupe policy) a new verification run record and begin executing the verification checks using existing background processing.
- **FR-006 — Dedupe / idempotency**: If a verification run is already active for the same identity (tenant + provider connection + flow), the system MUST NOT start a duplicate active run; it MUST present a clear “already running” outcome and an affordance to view the active run/report.
If no run is active (including when the most recent run is completed or failed), “Start verification” MUST create a new run.
- **FR-007 — Capability-first authorization**: Permission checks for viewing and starting verification MUST reference the canonical capability registry (no string-literal capability checks in feature code).
- **FR-008 — RBAC UX semantics**: Non-members attempting to access tenant-scoped verification pages/routes MUST receive not-found responses. Members lacking the “start” capability MUST be able to view reports but MUST NOT be able to start verification (UI disabled + server-side enforcement).
Viewing reports MUST NOT require the start capability.
- **FR-009 — Standardized UI semantics**: The viewer MUST render consistent status labels, a summary banner (e.g., Ready / Needs attention / Blocked), and per-check expandable details with standardized “Next steps” calls-to-action.
- **FR-010 — Reuse across suite**: The framework MUST be adoptable by multiple verification flows without re-implementing viewer logic, including: managed tenant onboarding verification, provider connection verification, RBAC setup verification, consent & permission verification, and future readiness/health checks.
- **FR-011 — Auditing**: Starting and completing verification MUST emit audit events with stable action identifiers and redaction rules, recording minimal metadata (workspace/tenant identifiers, run identifier, and result counts).
### Key Entities *(include if feature involves data)*
- **Verification Flow**: A named verification context (e.g., managed tenant onboarding) that defines which checks run.
- **Verification Identity (Scope)**: The set of identifiers that uniquely represent “what is being verified” (tenant + provider connection + flow).
- **Verification Run**: A single execution attempt for a given identity that produces a report (and is auditable).
- **Verification Report**: A versioned, structured document attached to a run, containing summary and check results.
- **Check Definition**: A reusable definition of an atomic readiness check (key, title, expected preconditions, severity, blocking behavior).
- **Check Result**: The outcome of executing a check within a report.
- **Reason Code**: A stable, machine-readable classification of why a check is pass/fail/warn/skip.
- **Next Step**: An actionable remediation hint (label + optional destination/action) that helps the operator resolve a failed check.
- **Evidence Pointer**: Safe references that support diagnostics (IDs, masked strings, hashes), without exposing secrets.
### Assumptions
- A run-tracking mechanism already exists and can store an attached, versioned verification report per run.
- A canonical capability registry exists and is the source of truth for permission checks.
- An audit logging mechanism exists that can record start/complete events with redaction.
- Verification execution uses existing background processing patterns (no new observability platform is introduced).
### Dependencies
- Workspace membership and tenant-scoped authorization boundaries are already modeled.
- Run visibility rules support “deny-as-not-found” behavior for non-members.
- UI surfaces exist (or can be added) where “Next steps” can route users for remediation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Clarity)**: In a usability test with a pre-seeded failed report, 90% of operators can identify the top blocking failure and the recommended next step within 60 seconds.
- **SC-002 (Determinism)**: When “Start verification” is triggered repeatedly for the same identity while a run is active, the system starts at most 1 active run (0 duplicates) and always provides a path to view the active run.
- **SC-003 (Safety / data minimization)**: Verification reports contain no secrets or tokens; evidence is limited to safe pointers (validated by automated tests and/or static checks).
- **SC-004 (Performance)**: The verification report viewer renders within 200ms server time for a typical report of up to 50 checks.
- **SC-005 (Authorization)**: Non-member access to tenant-scoped verification pages results in not-found responses in 100% of tested cases; members without the start capability cannot execute start actions in 100% of tested cases.

View File

@ -0,0 +1,120 @@
# Tasks: 074 Verification Checklist Framework
**Input**: Design documents from `/specs/074-verification-checklist/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: Required (Pest).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 [US1] Add example reports under specs: `specs/074-verification-checklist/contracts/examples/*.json` (pass/fail/warn/running) aligned to `contracts/verification-report.schema.json`.
- [x] T002 [P] [US1] Add a small schema validation helper for reports (pure PHP, no external deps) in `app/Support/Verification/VerificationReportSchema.php` (version parsing + shape validation + graceful fallback).
- [x] T003 [P] [US1] Add report redaction/sanitization utility in `app/Support/Verification/VerificationReportSanitizer.php` (denylist keys/values; enforce evidence pointers only).
- [x] T004 [US1] Add value objects (or typed arrays) for report/check concepts in `app/Support/Verification/*` (status/severity enums or constants) to avoid ad-hoc strings throughout UI.
**RBAC & UX prereqs**
- [x] T005 [US3] Decide and document the start capability used per verification flow (v1: use `Capabilities::PROVIDER_RUN` for `provider.connection.check`; prefer existing constants in `app/Support/Auth/Capabilities.php`).
- [x] T006 [US3] Add/confirm central UI enforcement helper usage for “visible-but-disabled with tooltip” in verification start UI (use tenant-scoped `app/Support/Rbac/UiEnforcement.php` with a resolved `Tenant` record).
**Badges (BADGE-001)**
- [x] T007 [P] [US1] Add badge domains for verification status/severity in `app/Support/Badges/BadgeDomain.php`.
- [x] T008 [P] [US1] Add domain mappers in `app/Support/Badges/Domains/*` (e.g., `VerificationCheckStatusBadge`, `VerificationCheckSeverityBadge`).
- [x] T009 [US1] Register domains in `app/Support/Badges/BadgeCatalog.php`.
- [x] T010 [US1] Add mapping tests for new badge domains in `tests/Unit/Badges/*`.
**Checkpoint**: Report contract + sanitizer + badge domains exist; UI work can start.
---
## Phase 2: User Story 1 — Operator sees whats wrong (Priority: P1)
**Goal**: Render a structured, DB-only verification report viewer for a run.
**Independent Test**: Seed an `OperationRun` with `context.verification_report` and assert the viewer renders the correct summary + per-check details, with no outbound HTTP.
### Tests (write first)
- [x] T011 [US1] Add a viewer DB-only test (no outbound HTTP, no job dispatch) in `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` using `Http::fake()` + `Bus::fake()` and asserting no requests / no dispatch during page render (including a second render to cover Livewire refresh/poll paths).
- [x] T012 [US1] Add a redaction test in `tests/Feature/Verification/VerificationReportRedactionTest.php` to ensure forbidden keys/values never appear in stored/rendered evidence.
- [x] T013 [US1] Add a “malformed/missing report” viewer test in `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` (safe empty state).
### Implementation
- [x] T014 [US1] Create a reusable viewer Blade partial in `resources/views/filament/components/verification-report-viewer.blade.php` (summary banner + counts + collapsible checks + next steps links-only).
- [x] T015 [US1] Create a Filament view entry/helper to render the viewer from an `OperationRun` in `app/Filament/Support/VerificationReportViewer.php` (or existing Filament helpers location), using only DB values.
- [x] T016 [US1] Integrate viewer into Monitoring → Operations run view page: update `app/Filament/Resources/OperationRunResource.php` (infolist) to show the verification report section when `context.verification_report` exists.
**Checkpoint**: A seeded report is readable in Monitoring; viewer is DB-only.
---
## Phase 3: User Story 2 — Deterministic starts (Priority: P2)
**Goal**: Starting verification is idempotent while active (dedupe) and guides users to the active run.
**Independent Test**: Start verification twice for the same identity and assert a single active run is used.
### Tests
- [x] T017 [US2] Add a dedupe regression test in `tests/Feature/Verification/VerificationStartDedupeTest.php` asserting repeated starts reuse the same active run (leveraging the existing `OperationRunService::ensureRunWithIdentity()` behavior).
- [x] T018 [US2] Add a “new run after completion” test in `tests/Feature/Verification/VerificationStartAfterCompletionTest.php`.
### Implementation
- [x] T019 [US2] Add (or adapt) a small “start verification” service wrapper in `app/Services/Verification/StartVerification.php` that: authorizes, creates/reuses a run identity, enqueues a verifier job, and returns the run.
- [x] T020 [US2] Update the managed tenant onboarding verification step to route through the shared starter and replace the binary status UI with the shared verification report viewer (or a safe empty state) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`.
- [x] T021 [US2] Update provider connection verification start surface(s) (where present) to route through the same shared starter.
**Checkpoint**: Starts are deterministic and route users to the active run.
---
## Phase 4: User Story 3 — Least-privilege and safe disclosure (Priority: P3)
**Goal**: View vs start capability split; non-members get 404; members lacking start capability get 403 on execution.
**Independent Test**: Readonly member can view report but cannot start; non-member cannot discover tenant/run.
### Tests
- [x] T022 [US3] Add authorization tests for view vs start in `tests/Feature/Verification/VerificationAuthorizationTest.php` covering:
- tenant non-member → 404 on view + start
- tenant member without start capability → can view, start returns forbidden (403)
- tenant member with start capability → can start
### Implementation
- [x] T023 [US3] Ensure start actions enforce server-side authorization via Gate/Policy (no UI-only enforcement) and use capability constants from `app/Support/Auth/Capabilities.php`.
- [x] T024 [US3] Ensure tenant-scope non-membership yields deny-as-not-found behavior for verification routes/actions (align with existing tenant routing patterns and helpers).
**Checkpoint**: Authorization behavior matches RBAC-UX contract.
---
## Phase 5: Audit & Completion Events (Cross-cutting)
- [x] T025 [US1] Add a stable audit action ID for verification completion in `app/Support/Audit/AuditActionId.php`.
- [x] T026 [US1] Emit a completion audit event when a verification run finalizes (where run completion is set) using `app/Services/Audit/WorkspaceAuditLogger.php` with redacted metadata (run id + counts only).
- [x] T030 [US1] Add a report writer in `app/Support/Verification/VerificationReportWriter.php` that builds `context.verification_report`, derives `summary.overall` deterministically, enforces reason codes + evidence pointer-only policy, and runs sanitizer before persistence.
- [x] T031 [US1] Integrate the report writer into `app/Jobs/ProviderConnectionHealthCheckJob.php` so `provider.connection.check` writes a compliant `verification_report` to the run (both success and failure paths) before marking the run completed.
- [x] T032 [US1] Add a report-writing integration test in `tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php` ensuring the run ends with a valid, sanitized `context.verification_report` (and no forbidden evidence fields).
---
## Phase 6: Polish & Regression Guards
- [x] T027 [P] Add UI polish for empty/missing report state in the viewer (no leaks of internal details).
- [x] T028 Run formatting: `vendor/bin/sail bin pint --dirty`.
- [x] T029 Run targeted tests: `vendor/bin/sail artisan test --compact tests/Feature/Verification`.
---
## Dependencies & Execution Order
- Phase 1 (Foundational) blocks all other phases.
- US1 can start after Phase 1; US2/US3 can proceed after Phase 1 but should reuse US1 primitives (viewer + sanitizer + badges).
- Audit completion (Phase 5) depends on the shared verification job/service that finalizes runs.

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftProviderHealthCheck;
use App\Support\Audit\AuditActionId;
use App\Support\Verification\VerificationReportSchema;
it('writes a sanitized verification report for failed provider connection checks', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'tenant_id' => (string) $connection->entra_tenant_id,
'client_id' => fake()->uuid(),
'client_secret' => fake()->sha1(),
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('getOrganization')
->once()
->andReturn(new GraphResponse(false, [], 401, ['Bearer super-secret-token']));
});
$job = new ProviderConnectionHealthCheckJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(
healthCheck: app(MicrosoftProviderHealthCheck::class),
runs: app(OperationRunService::class),
);
$run = $run->fresh();
expect($run)->not->toBeNull();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('failed');
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
expect($report)->toBeArray();
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
expect(json_encode($report))->not->toContain('Bearer ');
expect($report['checks'][0]['reason_code'] ?? null)->toBe('authentication_failed');
foreach (($report['checks'] ?? []) as $check) {
expect($check)->toBeArray();
foreach (($check['evidence'] ?? []) as $pointer) {
expect($pointer)->toBeArray();
expect(array_keys($pointer))->toEqualCanonicalizing(['kind', 'value']);
}
}
$audit = AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('action', AuditActionId::VerificationCompleted->value)
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit?->metadata)->toMatchArray([
'operation_run_id' => (int) $run->getKey(),
]);
});
it('writes a verification report for successful provider connection checks', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'tenant_id' => (string) $connection->entra_tenant_id,
'client_id' => fake()->uuid(),
'client_secret' => fake()->sha1(),
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('getOrganization')
->once()
->andReturn(new GraphResponse(true, [
'id' => 'org_123',
'displayName' => 'Org 123',
], 200));
});
$job = new ProviderConnectionHealthCheckJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(
healthCheck: app(MicrosoftProviderHealthCheck::class),
runs: app(OperationRunService::class),
);
$run = $run->fresh();
expect($run)->not->toBeNull();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
expect($report)->toBeArray();
expect(VerificationReportSchema::isValidReport($report))->toBeTrue();
expect($report['summary']['counts'] ?? [])->toMatchArray([
'total' => 1,
'pass' => 1,
'fail' => 0,
]);
});

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Verification\StartVerification;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Queue;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
it('returns 404 for non-members on verification view and start', function (): void {
Queue::fake();
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'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,
),
],
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->assertStatus(404);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
expect(fn () => app(StartVerification::class)->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
))->toThrow(NotFoundHttpException::class);
});
it('allows readonly members to view verification reports but forbids starting verification', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'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,
),
],
]);
$this->actingAs($user)
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
->assertOk()
->assertSee('Verification report');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
expect(fn () => app(StartVerification::class)->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
))->toThrow(AuthorizationException::class);
});
it('allows members with start capability to start verification', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$result = app(StartVerification::class)->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
);
expect($result->status)->toBe('started');
expect($result->run->type)->toBe('provider.connection.check');
expect($result->run->tenant_id)->toBe((int) $tenant->getKey());
expect($result->run->context)->toMatchArray([
'provider_connection_id' => (int) $connection->getKey(),
]);
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('shows a safe empty state when a verification report is missing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [],
]);
assertNoOutboundHttp(function () use ($run): void {
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
->assertSee('Verification report')
->assertSee('Verification report unavailable');
});
});
it('shows a safe empty state when a verification report is malformed', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => [
'schema_version' => '1.0.0',
'flow' => 'provider.connection.check',
],
],
]);
assertNoOutboundHttp(function () use ($run): void {
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
->assertSee('Verification report')
->assertSee('Verification report unavailable');
});
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('redacts forbidden evidence fields in rendered verification reports', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$report = json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
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([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => $report,
],
]);
assertNoOutboundHttp(function () use ($run): void {
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
->assertSee('Verification report')
->assertSee('Token acquisition works')
->assertDontSee('access_token')
->assertDontSee('Bearer abc.def.ghi')
->assertDontSee('super-secret');
});
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
it('renders the verification report viewer DB-only (no outbound HTTP, no job dispatch)', function (): void {
Bus::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$report = json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => $report,
],
]);
assertNoOutboundHttp(function () use ($run): void {
$component = Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
->assertSee('Verification report')
->assertSee('Blocked')
->assertSee('Token acquisition works');
$component
->call('$refresh')
->assertSee('Token acquisition works');
});
Bus::assertNothingDispatched();
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\OperationRunService;
use App\Services\Verification\StartVerification;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
it('creates a new verification run after the previous run is completed', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$starter = app(StartVerification::class);
$first = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
);
/** @var OperationRun $firstRun */
$firstRun = $first->run->refresh();
app(OperationRunService::class)->updateRun(
$firstRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$second = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
);
expect($second->status)->toBe('started');
expect($second->run->getKey())->not->toBe($firstRun->getKey());
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(2);
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 2);
});

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\Verification\StartVerification;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
it('dedupes verification starts while a run is active', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$starter = app(StartVerification::class);
$first = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
extraContext: ['wizard' => ['flow' => 'managed_tenant_onboarding']],
);
$second = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connection,
initiator: $user,
extraContext: ['wizard' => ['flow' => 'managed_tenant_onboarding']],
);
expect($first->run->getKey())->toBe($second->run->getKey());
expect($first->status)->toBe('started');
expect($second->status)->toBe('deduped');
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
});

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps verification check status values to canonical badge semantics', function (): void {
$pass = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'pass');
expect($pass->label)->toBe('Pass');
expect($pass->color)->toBe('success');
$fail = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
expect($fail->label)->toBe('Fail');
expect($fail->color)->toBe('danger');
$warn = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'warn');
expect($warn->label)->toBe('Warn');
expect($warn->color)->toBe('warning');
$skip = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'skip');
expect($skip->label)->toBe('Skipped');
expect($skip->color)->toBe('gray');
$running = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
});
it('maps verification check severity values to canonical badge semantics', function (): void {
$info = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'info');
expect($info->label)->toBe('Info');
expect($info->color)->toBe('gray');
$low = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'low');
expect($low->label)->toBe('Low');
expect($low->color)->toBe('info');
$medium = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'medium');
expect($medium->label)->toBe('Medium');
expect($medium->color)->toBe('warning');
$high = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'high');
expect($high->label)->toBe('High');
expect($high->color)->toBe('danger');
$critical = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
expect($critical->label)->toBe('Critical');
expect($critical->color)->toBe('danger');
});
it('maps verification report overall values to canonical badge semantics', function (): void {
$ready = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'ready');
expect($ready->label)->toBe('Ready');
expect($ready->color)->toBe('success');
$needsAttention = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
expect($needsAttention->label)->toBe('Needs attention');
expect($needsAttention->color)->toBe('warning');
$blocked = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'blocked');
expect($blocked->label)->toBe('Blocked');
expect($blocked->color)->toBe('danger');
$running = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
});