173 lines
7.1 KiB
PHP
173 lines
7.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\OperationalControlActivation;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Models\WorkspaceSetting;
|
|
use App\Support\Ai\AiDataClassification;
|
|
use App\Support\Ai\AiDecisionReasonCode;
|
|
use App\Support\Ai\AiExecutionRequest;
|
|
use App\Support\Ai\AiProviderClass;
|
|
use App\Support\Ai\GovernedAiExecutionBoundary;
|
|
use App\Support\Audit\AuditActionId;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
/**
|
|
* @return array{0: Workspace, 1: User}
|
|
*/
|
|
function aiPolicyWorkspace(string $policyMode = 'private_only'): array
|
|
{
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'manager',
|
|
]);
|
|
|
|
WorkspaceSetting::query()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'domain' => 'ai',
|
|
'key' => 'policy_mode',
|
|
'value' => $policyMode,
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
]);
|
|
|
|
return [$workspace, $user];
|
|
}
|
|
|
|
it('allows approved local-private support-diagnostics requests and writes bounded audit metadata', function (): void {
|
|
[$workspace, $user] = aiPolicyWorkspace();
|
|
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
|
|
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
workspace: $workspace,
|
|
tenant: $tenant,
|
|
actor: $user,
|
|
useCaseKey: 'support_diagnostics.summary_draft',
|
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
|
sourceFamily: 'support_diagnostics',
|
|
callerSurface: 'support_diagnostics',
|
|
contextFingerprint: 'support_diagnostics:summary:v1',
|
|
)));
|
|
|
|
expect($decision->isAllowed())->toBeTrue()
|
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::Allowed)
|
|
->and($decision->workspaceAiPolicyMode)->toBe('private_only')
|
|
->and($decision->matchedOperationalControlScope)->toBeNull();
|
|
|
|
$audit = AuditLog::query()->latest('id')->first();
|
|
|
|
expect($audit)->not->toBeNull()
|
|
->and($audit?->action)->toBe(AuditActionId::AiExecutionDecisionEvaluated->value)
|
|
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
|
->and(data_get($audit?->metadata, 'decision_outcome'))->toBe('allowed')
|
|
->and(data_get($audit?->metadata, 'decision_reason'))->toBe(AiDecisionReasonCode::Allowed->value)
|
|
->and(data_get($audit?->metadata, 'use_case_key'))->toBe('support_diagnostics.summary_draft')
|
|
->and(data_get($audit?->metadata, 'requested_provider_class'))->toBe('local_private')
|
|
->and(data_get($audit?->metadata, 'data_classifications'))->toBe(['redacted_support_summary'])
|
|
->and(data_get($audit?->metadata, 'context_fingerprint'))->toBe('support_diagnostics:summary:v1')
|
|
->and(data_get($audit?->metadata, 'prompt_text'))->toBeNull()
|
|
->and(data_get($audit?->metadata, 'output_text'))->toBeNull();
|
|
});
|
|
|
|
it('blocks external-public provider classes before any provider resolution', function (): void {
|
|
[$workspace, $user] = aiPolicyWorkspace();
|
|
|
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
actor: $user,
|
|
useCaseKey: 'product_knowledge.answer_draft',
|
|
requestedProviderClass: AiProviderClass::ExternalPublic->value,
|
|
dataClassifications: [
|
|
AiDataClassification::ProductKnowledge->value,
|
|
AiDataClassification::OperationalMetadata->value,
|
|
],
|
|
sourceFamily: 'product_knowledge',
|
|
callerSurface: 'product_knowledge',
|
|
contextFingerprint: 'product_knowledge:answer:v1',
|
|
)));
|
|
|
|
expect($decision->isBlocked())->toBeTrue()
|
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::ProviderClassBlocked)
|
|
->and($decision->matchedOperationalControlScope)->toBeNull();
|
|
});
|
|
|
|
it('blocks disallowed data classifications before any provider resolution', function (): void {
|
|
[$workspace, $user] = aiPolicyWorkspace();
|
|
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
|
|
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
workspace: $workspace,
|
|
tenant: $tenant,
|
|
actor: $user,
|
|
useCaseKey: 'support_diagnostics.summary_draft',
|
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
dataClassifications: [AiDataClassification::RawProviderPayload->value],
|
|
sourceFamily: 'support_diagnostics',
|
|
callerSurface: 'support_diagnostics',
|
|
contextFingerprint: 'support_diagnostics:raw:v1',
|
|
)));
|
|
|
|
expect($decision->isBlocked())->toBeTrue()
|
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::DataClassificationBlocked);
|
|
});
|
|
|
|
it('blocks unregistered use cases', function (): void {
|
|
[$workspace, $user] = aiPolicyWorkspace();
|
|
|
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
actor: $user,
|
|
useCaseKey: 'customer_email.reply',
|
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
dataClassifications: [AiDataClassification::ProductKnowledge->value],
|
|
sourceFamily: 'product_knowledge',
|
|
callerSurface: 'product_knowledge',
|
|
contextFingerprint: 'customer_email:reply:v1',
|
|
)));
|
|
|
|
expect($decision->isBlocked())->toBeTrue()
|
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::UnregisteredUseCase);
|
|
});
|
|
|
|
it('lets the ai execution operational control override an otherwise valid request', function (): void {
|
|
[$workspace, $user] = aiPolicyWorkspace();
|
|
|
|
OperationalControlActivation::factory()->forGlobalScope()->create([
|
|
'control_key' => 'ai.execution',
|
|
'reason_text' => 'Paused for AI rollout review.',
|
|
]);
|
|
|
|
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
workspace: $workspace,
|
|
tenant: null,
|
|
actor: $user,
|
|
useCaseKey: 'product_knowledge.answer_draft',
|
|
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
dataClassifications: [
|
|
AiDataClassification::ProductKnowledge->value,
|
|
AiDataClassification::OperationalMetadata->value,
|
|
],
|
|
sourceFamily: 'product_knowledge',
|
|
callerSurface: 'product_knowledge',
|
|
contextFingerprint: 'product_knowledge:answer:v1',
|
|
)));
|
|
|
|
expect($decision->isBlocked())->toBeTrue()
|
|
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::OperationalControlPaused)
|
|
->and($decision->matchedOperationalControlScope)->toBe('global');
|
|
});
|