TenantAtlas/apps/platform/tests/Unit/Support/Ai/GovernedAiExecutionBoundaryTest.php
ahmido ff3392892b
Some checks failed
Main Confidence / confidence (push) Failing after 56s
Merge 248-private-ai-policy-foundation into dev (#288)
Automated PR: merge branch 248-private-ai-policy-foundation into dev (created by Copilot)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #288
2026-04-27 21:18:37 +00:00

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');
});