469 lines
21 KiB
PHP
469 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
|
|
use App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceInstancesTable;
|
|
use App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceTypesTable;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Models\TenantConfigurationSupportedScope;
|
|
use App\Models\User;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Auth\ManagedEnvironmentAccessDecision;
|
|
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
|
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\ClaimState;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use App\Support\TenantConfiguration\IdentityState;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
use App\Support\TenantConfiguration\SupportState;
|
|
use Filament\Actions\Action;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
function coverageV2ActingAs(User $user, ManagedEnvironment $environment): void
|
|
{
|
|
test()->actingAs($user);
|
|
$environment->makeCurrent();
|
|
Filament::setTenant($environment, true);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* contentType: TenantConfigurationResourceType,
|
|
* blockedType: TenantConfigurationResourceType,
|
|
* betaType: TenantConfigurationResourceType,
|
|
* connection: ProviderConnection,
|
|
* contentResource: TenantConfigurationResource,
|
|
* blockedResource: TenantConfigurationResource,
|
|
* betaResource: TenantConfigurationResource
|
|
* }
|
|
*/
|
|
function seedCoverageV2ReadinessScenario(ManagedEnvironment $environment): array
|
|
{
|
|
$connection = ProviderConnection::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'display_name' => 'Spec 418 Microsoft provider',
|
|
]);
|
|
|
|
$contentType = TenantConfigurationResourceType::factory()->create([
|
|
'canonical_type' => 'spec418ContentType',
|
|
'display_name' => 'Spec 418 content type',
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'support_state' => SupportState::Supported->value,
|
|
'default_coverage_level' => CoverageLevel::ContentBacked->value,
|
|
'default_claim_state' => ClaimState::ClaimAllowed->value,
|
|
]);
|
|
|
|
$blockedType = TenantConfigurationResourceType::factory()->create([
|
|
'canonical_type' => 'spec418BlockedType',
|
|
'display_name' => 'Spec 418 blocked type',
|
|
'source_class' => SourceClass::GraphV1Fallback->value,
|
|
'support_state' => SupportState::FallbackSupported->value,
|
|
'default_coverage_level' => CoverageLevel::Detected->value,
|
|
'default_claim_state' => ClaimState::ClaimLimited->value,
|
|
]);
|
|
|
|
$betaType = TenantConfigurationResourceType::factory()->create([
|
|
'canonical_type' => 'spec418BetaType',
|
|
'display_name' => 'Spec 418 beta type',
|
|
'source_class' => SourceClass::GraphBetaExperimental->value,
|
|
'support_state' => SupportState::Experimental->value,
|
|
'default_coverage_level' => CoverageLevel::Detected->value,
|
|
'default_claim_state' => ClaimState::ClaimBlocked->value,
|
|
]);
|
|
|
|
TenantConfigurationSupportedScope::factory()->create([
|
|
'scope_key' => 'spec418_scope',
|
|
'display_name' => 'Spec 418 scope',
|
|
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
|
|
'included_resource_types' => [$contentType->canonical_type],
|
|
'allow_graph_fallback' => false,
|
|
'allow_beta' => false,
|
|
'customer_claims_allowed' => false,
|
|
]);
|
|
|
|
$contentResource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $contentType->getKey(),
|
|
'canonical_type' => $contentType->canonical_type,
|
|
'source_display_name' => 'Spec 418 captured assignment filter',
|
|
'source_class' => SourceClass::Tcm->value,
|
|
'latest_evidence_state' => EvidenceState::ContentBacked->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::ClaimAllowed->value,
|
|
'latest_captured_at' => now(),
|
|
]);
|
|
|
|
$blockedResource = TenantConfigurationResource::factory()
|
|
->identityConflict()
|
|
->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $blockedType->getKey(),
|
|
'canonical_type' => $blockedType->canonical_type,
|
|
'source_display_name' => 'Spec 418 conflicting assignment filter',
|
|
'source_class' => SourceClass::GraphV1Fallback->value,
|
|
'latest_evidence_state' => EvidenceState::PermissionBlocked->value,
|
|
'latest_captured_at' => now(),
|
|
]);
|
|
|
|
$betaResource = TenantConfigurationResource::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $betaType->getKey(),
|
|
'canonical_type' => $betaType->canonical_type,
|
|
'source_display_name' => 'Spec 418 beta resource',
|
|
'source_class' => SourceClass::GraphBetaExperimental->value,
|
|
'latest_evidence_state' => EvidenceState::NotCaptured->value,
|
|
'latest_identity_state' => IdentityState::Stable->value,
|
|
'latest_claim_state' => ClaimState::ClaimBlocked->value,
|
|
]);
|
|
|
|
foreach ([$contentResource, $blockedResource] as $resource) {
|
|
$run = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'type' => OperationRunType::TenantConfigurationCapture->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
]);
|
|
|
|
$evidence = TenantConfigurationResourceEvidence::factory()->create([
|
|
'resource_id' => (int) $resource->getKey(),
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $environment->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'resource_type_id' => (int) $resource->resource_type_id,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'payload_hash' => $resource->is($blockedResource)
|
|
? str_repeat('b', 64)
|
|
: str_repeat('a', 64),
|
|
'raw_payload' => ['secret' => 'raw-response-secret'],
|
|
'normalized_payload' => ['secret' => 'normalized-secret'],
|
|
'permission_context' => ['token' => 'permission-secret'],
|
|
'evidence_state' => $resource->latest_evidence_state->value,
|
|
'coverage_level' => $resource->is($blockedResource)
|
|
? CoverageLevel::Detected->value
|
|
: CoverageLevel::ContentBacked->value,
|
|
'capture_outcome' => $resource->is($blockedResource)
|
|
? CaptureOutcome::BlockedPermission->value
|
|
: CaptureOutcome::Captured->value,
|
|
'source_contract_key' => 'spec418.contract',
|
|
'source_version' => 'v1.0',
|
|
'source_schema_hash' => 'spec418-schema-hash',
|
|
'captured_at' => now(),
|
|
]);
|
|
|
|
$resource->forceFill([
|
|
'latest_evidence_id' => (int) $evidence->getKey(),
|
|
'latest_payload_hash' => (string) $evidence->payload_hash,
|
|
])->save();
|
|
}
|
|
|
|
return [
|
|
'contentType' => $contentType,
|
|
'blockedType' => $blockedType,
|
|
'betaType' => $betaType,
|
|
'connection' => $connection,
|
|
'contentResource' => $contentResource->refresh(),
|
|
'blockedResource' => $blockedResource->refresh(),
|
|
'betaResource' => $betaResource->refresh(),
|
|
];
|
|
}
|
|
|
|
it('renders the read-only Coverage v2 readiness surface with scoped summary and no raw payloads', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
seedCoverageV2ReadinessScenario($environment);
|
|
$foreignEnvironment = ManagedEnvironment::factory()->create(['workspace_id' => (int) $environment->workspace_id]);
|
|
ProviderConnection::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'managed_environment_id' => (int) $foreignEnvironment->getKey(),
|
|
'display_name' => 'Foreign provider must not render',
|
|
]);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
bindFailHardGraphClient();
|
|
|
|
$response = assertNoOutboundHttp(fn () => $this->get(CoverageV2Readiness::getUrl(tenant: $environment)));
|
|
|
|
$response->assertOk()
|
|
->assertSee('Coverage v2 Readiness')
|
|
->assertSee('Blocked')
|
|
->assertSee('Activation readiness')
|
|
->assertSee('Reason')
|
|
->assertSee('Identity conflict is the highest-priority activation blocker.')
|
|
->assertSee('Next step')
|
|
->assertSee('Inspect Spec 418 conflicting assignment filter and resolve the blocker before cutover planning.')
|
|
->assertSee('Resource types')
|
|
->assertSee('Activation blockers')
|
|
->assertSee('Identity conflict')
|
|
->assertSee('Claim blocked')
|
|
->assertSee('Spec 418 content type')
|
|
->assertSee('Spec 418 conflicting assignment filter')
|
|
->assertDontSee('raw-response-secret')
|
|
->assertDontSee('normalized-secret')
|
|
->assertDontSee('permission-secret')
|
|
->assertDontSee('Foreign provider must not render')
|
|
->assertDontSee('customer-ready')
|
|
->assertDontSee('Evidence gaps');
|
|
});
|
|
|
|
it('explains unknown readiness when no Coverage v2 resources are captured', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
$response = $this->get(CoverageV2Readiness::getUrl(tenant: $environment));
|
|
|
|
$response->assertOk()
|
|
->assertSee('Unknown')
|
|
->assertSee('Activation readiness')
|
|
->assertSee('Reason')
|
|
->assertSee('No Coverage v2 resource rows exist for this managed environment.')
|
|
->assertSee('Next step')
|
|
->assertSee('Review capture prerequisites before using Coverage v2 as activation proof.')
|
|
->assertSee('No captured Coverage v2 resources');
|
|
|
|
expect($response->getContent())
|
|
->toContain('No captured Coverage v2 resources')
|
|
->and(strpos((string) $response->getContent(), 'No captured Coverage v2 resources'))
|
|
->toBeLessThan(strpos((string) $response->getContent(), 'Resource type registry'));
|
|
});
|
|
|
|
it('derives summary counts and top blockers from Coverage v2 state only', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
$readModel = app(CoverageV2ReadinessReadModel::class);
|
|
$summary = $readModel->summary($environment);
|
|
$blockers = $readModel->activationBlockers($environment)->pluck('blocker')->all();
|
|
|
|
expect($summary)
|
|
->toMatchArray([
|
|
'readiness_state' => 'blocked',
|
|
'resources_total' => 3,
|
|
'content_backed_count' => 1,
|
|
'activation_blocker_count' => 6,
|
|
'identity_conflict_count' => 1,
|
|
'claim_allowed_count' => 1,
|
|
'claim_blocked_count' => 2,
|
|
])
|
|
->and($summary['beta_experimental_count'])
|
|
->toBeGreaterThanOrEqual(1)
|
|
->and($summary['graph_fallback_count'])
|
|
->toBeGreaterThanOrEqual(1)
|
|
->and(array_slice($blockers, 0, 5))
|
|
->toBe([
|
|
'identity_conflict',
|
|
'claim_blocked',
|
|
'permission_blocked',
|
|
'not_captured',
|
|
'beta_experimental',
|
|
]);
|
|
});
|
|
|
|
it('renders resource type registry filters and supported-scope inclusion', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
$scenario = seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(CoverageV2ResourceTypesTable::class)
|
|
->assertTableColumnExists('display_name')
|
|
->assertTableColumnExists('canonical_type')
|
|
->assertTableColumnExists('source_class')
|
|
->assertTableColumnExists('support_state')
|
|
->assertTableColumnExists('default_coverage_level')
|
|
->assertTableColumnExists('supported_scope')
|
|
->assertTableColumnExists('default_claim_state')
|
|
->assertTableActionExists('inspect', fn (Action $action): bool => $action->getLabel() === 'Inspect' && ! $action->isConfirmationRequired(), $scenario['contentType'])
|
|
->searchTable('Spec 418')
|
|
->assertCanSeeTableRecords([$scenario['contentType'], $scenario['blockedType'], $scenario['betaType']])
|
|
->filterTable('supported_scope', 'spec418_scope')
|
|
->assertCanSeeTableRecords([$scenario['contentType']])
|
|
->assertCanNotSeeTableRecords([$scenario['blockedType'], $scenario['betaType']])
|
|
->removeTableFilters()
|
|
->filterTable('beta_experimental', 'yes')
|
|
->assertCanSeeTableRecords([$scenario['betaType']])
|
|
->assertCanNotSeeTableRecords([$scenario['contentType'], $scenario['blockedType']]);
|
|
});
|
|
|
|
it('renders resource instance states, filters, and one read-only inspect action', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
$scenario = seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(CoverageV2ResourceInstancesTable::class, ['environmentId' => (int) $environment->getKey()])
|
|
->assertTableColumnExists('source_display_name')
|
|
->assertTableColumnExists('resourceType.display_name')
|
|
->assertTableColumnExists('providerConnection.display_name')
|
|
->assertTableColumnExists('coverage_level')
|
|
->assertTableColumnExists('latest_evidence_state')
|
|
->assertTableColumnExists('latest_identity_state')
|
|
->assertTableColumnExists('latest_claim_state')
|
|
->assertTableColumnExists('latest_payload_hash')
|
|
->assertCanSeeTableRecords([$scenario['contentResource'], $scenario['blockedResource']])
|
|
->assertTableColumnFormattedStateSet('latest_claim_state', 'Claim blocked', $scenario['blockedResource'])
|
|
->assertTableColumnFormattedStateSet('latest_identity_state', 'Identity conflict', $scenario['blockedResource'])
|
|
->assertTableActionExists('inspect', fn (Action $action): bool => $action->getLabel() === 'Inspect' && ! $action->isConfirmationRequired(), $scenario['blockedResource'])
|
|
->filterTable('latest_identity_state', IdentityState::IdentityConflict->value)
|
|
->assertCanSeeTableRecords([$scenario['blockedResource']])
|
|
->assertCanNotSeeTableRecords([$scenario['contentResource']]);
|
|
});
|
|
|
|
it('redacts raw evidence payload fields from inspect disclosure', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
$scenario = seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)
|
|
->inspectDetails($scenario['blockedResource'], $environment, $user);
|
|
$run = $scenario['blockedResource']->latestEvidence?->operationRun;
|
|
$outsider = User::factory()->create();
|
|
$outsiderDetails = app(CoverageV2ReadinessReadModel::class)
|
|
->inspectDetails($scenario['blockedResource'], $environment, $outsider);
|
|
|
|
$html = view('filament.modals.tenant-configuration.coverage-v2-resource-inspect', [
|
|
'details' => $details,
|
|
])->render();
|
|
|
|
expect($details)
|
|
->toHaveKey('identity_reason_code', 'same_scope_derived_identity_collision')
|
|
->toHaveKey('operation_run_url', OperationRunLinks::view($run, $environment))
|
|
->not->toHaveKeys(['raw_payload', 'normalized_payload', 'permission_context'])
|
|
->and($outsiderDetails['operation_run_url'] ?? null)
|
|
->toBeNull()
|
|
->and($html)
|
|
->toContain('Identity conflict')
|
|
->toContain('same_scope_derived_identity_collision')
|
|
->toContain('spec418-schema-hash')
|
|
->not->toContain('raw-response-secret')
|
|
->not->toContain('normalized-secret')
|
|
->not->toContain('permission-secret')
|
|
->not->toContain('raw_payload')
|
|
->not->toContain('normalized_payload')
|
|
->not->toContain('permission_context');
|
|
});
|
|
|
|
it('redacts raw registry metadata fields from resource type inspect disclosure', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
$scenario = seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
$details = app(CoverageV2ReadinessReadModel::class)
|
|
->resourceTypeInspectDetails($scenario['contentType'], 'spec418_scope');
|
|
|
|
$html = view('filament.modals.tenant-configuration.coverage-v2-resource-type-inspect', [
|
|
'details' => $details,
|
|
])->render();
|
|
|
|
expect($details)
|
|
->toHaveKey('canonical_type', 'spec418ContentType')
|
|
->not->toHaveKeys(['metadata', 'raw_payload', 'normalized_payload', 'permission_context'])
|
|
->and($html)
|
|
->toContain('Spec 418 scope')
|
|
->toContain('Content backed')
|
|
->toContain('Claim allowed')
|
|
->not->toContain('raw-response-secret')
|
|
->not->toContain('normalized-secret')
|
|
->not->toContain('permission-secret')
|
|
->not->toContain('metadata')
|
|
->not->toContain('raw_payload')
|
|
->not->toContain('normalized_payload')
|
|
->not->toContain('permission_context');
|
|
});
|
|
|
|
it('returns 404 when the actor is not a member of the requested workspace', function (): void {
|
|
[$owner, $environment] = createUserWithTenant(role: 'owner');
|
|
seedCoverageV2ReadinessScenario($environment);
|
|
$outsider = User::factory()->create();
|
|
|
|
$this->actingAs($outsider)
|
|
->get(CoverageV2Readiness::getUrl(tenant: $environment))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 404 when the actor belongs to the workspace but not the requested environment', function (): void {
|
|
[$owner, $environment] = createUserWithTenant(role: 'owner');
|
|
seedCoverageV2ReadinessScenario($environment);
|
|
$otherEnvironment = ManagedEnvironment::factory()->create(['workspace_id' => (int) $environment->workspace_id]);
|
|
$outsider = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $environment->workspace_id,
|
|
'user_id' => (int) $outsider->getKey(),
|
|
'role' => 'owner',
|
|
]);
|
|
|
|
DB::table('managed_environment_memberships')->insert([
|
|
'id' => (string) Str::uuid(),
|
|
'managed_environment_id' => (int) $otherEnvironment->getKey(),
|
|
'user_id' => (int) $outsider->getKey(),
|
|
'role' => 'owner',
|
|
'source' => 'manual',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
coverageV2ActingAs($outsider, $environment);
|
|
|
|
$this->get(CoverageV2Readiness::getUrl(tenant: $environment))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 403 when environment scope is valid but the capability is denied', function (): void {
|
|
[$user, $environment] = createUserWithTenant(role: 'owner');
|
|
seedCoverageV2ReadinessScenario($environment);
|
|
coverageV2ActingAs($user, $environment);
|
|
|
|
app()->instance(ManagedEnvironmentAccessScopeResolver::class, new class
|
|
{
|
|
public function decision(User $user, ManagedEnvironment $environment, ?string $requiredCapability = null): ManagedEnvironmentAccessDecision
|
|
{
|
|
return new ManagedEnvironmentAccessDecision(
|
|
workspaceId: (int) $environment->workspace_id,
|
|
managedEnvironmentId: (int) $environment->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
workspaceMember: true,
|
|
workspaceRole: 'owner',
|
|
explicitScopeRowsPresent: false,
|
|
managedEnvironmentAllowed: true,
|
|
failedBoundary: 'capability',
|
|
requiredCapability: $requiredCapability,
|
|
capabilityAllowed: false,
|
|
denialHttpStatus: 403,
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
$this->get(CoverageV2Readiness::getUrl(tenant: $environment))
|
|
->assertForbidden();
|
|
} finally {
|
|
app()->forgetInstance(ManagedEnvironmentAccessScopeResolver::class);
|
|
}
|
|
});
|