433 lines
14 KiB
PHP
433 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Mod\Agentic\Tests\Feature;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||
|
|
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
||
|
|
use Core\Mod\Agentic\Models\Task;
|
||
|
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet;
|
||
|
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList;
|
||
|
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet;
|
||
|
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList;
|
||
|
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet;
|
||
|
|
use Core\Tenant\Models\Workspace;
|
||
|
|
use Tests\TestCase;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Security tests for workspace isolation and SQL injection prevention.
|
||
|
|
*/
|
||
|
|
class SecurityTest extends TestCase
|
||
|
|
{
|
||
|
|
use RefreshDatabase;
|
||
|
|
|
||
|
|
private Workspace $workspace;
|
||
|
|
private Workspace $otherWorkspace;
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
parent::setUp();
|
||
|
|
$this->workspace = Workspace::factory()->create();
|
||
|
|
$this->otherWorkspace = Workspace::factory()->create();
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// StateSet Workspace Scoping Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_state_set_requires_workspace_context(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateSet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $plan->slug,
|
||
|
|
'key' => 'test_key',
|
||
|
|
'value' => 'test_value',
|
||
|
|
], []); // No workspace_id in context
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('workspace_id is required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_set_cannot_access_other_workspace_plans(): void
|
||
|
|
{
|
||
|
|
$otherPlan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->otherWorkspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateSet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $otherPlan->slug,
|
||
|
|
'key' => 'test_key',
|
||
|
|
'value' => 'test_value',
|
||
|
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('Plan not found', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_set_works_with_correct_workspace(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateSet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $plan->slug,
|
||
|
|
'key' => 'test_key',
|
||
|
|
'value' => 'test_value',
|
||
|
|
], ['workspace_id' => $this->workspace->id]);
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('success', $result);
|
||
|
|
$this->assertTrue($result['success']);
|
||
|
|
$this->assertEquals('test_key', $result['state']['key']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// StateGet Workspace Scoping Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_state_get_requires_workspace_context(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
AgentWorkspaceState::create([
|
||
|
|
'agent_plan_id' => $plan->id,
|
||
|
|
'key' => 'test_key',
|
||
|
|
'value' => ['data' => 'secret'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $plan->slug,
|
||
|
|
'key' => 'test_key',
|
||
|
|
], []); // No workspace_id in context
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('workspace_id is required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_get_cannot_access_other_workspace_state(): void
|
||
|
|
{
|
||
|
|
$otherPlan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->otherWorkspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
AgentWorkspaceState::create([
|
||
|
|
'agent_plan_id' => $otherPlan->id,
|
||
|
|
'key' => 'secret_key',
|
||
|
|
'value' => ['data' => 'sensitive'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $otherPlan->slug,
|
||
|
|
'key' => 'secret_key',
|
||
|
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('Plan not found', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_get_works_with_correct_workspace(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
AgentWorkspaceState::create([
|
||
|
|
'agent_plan_id' => $plan->id,
|
||
|
|
'key' => 'test_key',
|
||
|
|
'value' => ['data' => 'allowed'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $plan->slug,
|
||
|
|
'key' => 'test_key',
|
||
|
|
], ['workspace_id' => $this->workspace->id]);
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('success', $result);
|
||
|
|
$this->assertTrue($result['success']);
|
||
|
|
$this->assertEquals('test_key', $result['key']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// StateList Workspace Scoping Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_state_list_requires_workspace_context(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateList();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $plan->slug,
|
||
|
|
], []); // No workspace_id in context
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('workspace_id is required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_list_cannot_access_other_workspace_states(): void
|
||
|
|
{
|
||
|
|
$otherPlan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->otherWorkspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
AgentWorkspaceState::create([
|
||
|
|
'agent_plan_id' => $otherPlan->id,
|
||
|
|
'key' => 'secret_key',
|
||
|
|
'value' => ['data' => 'sensitive'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new StateList();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'plan_slug' => $otherPlan->slug,
|
||
|
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('Plan not found', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// PlanGet Workspace Scoping Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_plan_get_requires_workspace_context(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new PlanGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'slug' => $plan->slug,
|
||
|
|
], []); // No workspace_id in context
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('workspace_id is required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_plan_get_cannot_access_other_workspace_plans(): void
|
||
|
|
{
|
||
|
|
$otherPlan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->otherWorkspace->id,
|
||
|
|
'title' => 'Secret Plan',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new PlanGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'slug' => $otherPlan->slug,
|
||
|
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('Plan not found', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_plan_get_works_with_correct_workspace(): void
|
||
|
|
{
|
||
|
|
$plan = AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'My Plan',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new PlanGet();
|
||
|
|
$result = $tool->handle([
|
||
|
|
'slug' => $plan->slug,
|
||
|
|
], ['workspace_id' => $this->workspace->id]);
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('success', $result);
|
||
|
|
$this->assertTrue($result['success']);
|
||
|
|
$this->assertEquals('My Plan', $result['plan']['title']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// PlanList Workspace Scoping Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_plan_list_requires_workspace_context(): void
|
||
|
|
{
|
||
|
|
$tool = new PlanList();
|
||
|
|
$result = $tool->handle([], []); // No workspace_id in context
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('error', $result);
|
||
|
|
$this->assertStringContainsString('workspace_id is required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_plan_list_only_returns_workspace_plans(): void
|
||
|
|
{
|
||
|
|
// Create plans in both workspaces
|
||
|
|
AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'My Plan',
|
||
|
|
]);
|
||
|
|
AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->otherWorkspace->id,
|
||
|
|
'title' => 'Other Plan',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tool = new PlanList();
|
||
|
|
$result = $tool->handle([], ['workspace_id' => $this->workspace->id]);
|
||
|
|
|
||
|
|
$this->assertArrayHasKey('success', $result);
|
||
|
|
$this->assertTrue($result['success']);
|
||
|
|
$this->assertEquals(1, $result['total']);
|
||
|
|
$this->assertEquals('My Plan', $result['plans'][0]['title']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Task Model Ordering Tests (SQL Injection Prevention)
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_task_order_by_priority_uses_parameterised_query(): void
|
||
|
|
{
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Low task',
|
||
|
|
'priority' => 'low',
|
||
|
|
'status' => 'pending',
|
||
|
|
]);
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Urgent task',
|
||
|
|
'priority' => 'urgent',
|
||
|
|
'status' => 'pending',
|
||
|
|
]);
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'High task',
|
||
|
|
'priority' => 'high',
|
||
|
|
'status' => 'pending',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tasks = Task::forWorkspace($this->workspace->id)
|
||
|
|
->orderByPriority()
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$this->assertEquals('Urgent task', $tasks[0]->title);
|
||
|
|
$this->assertEquals('High task', $tasks[1]->title);
|
||
|
|
$this->assertEquals('Low task', $tasks[2]->title);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_task_order_by_status_uses_parameterised_query(): void
|
||
|
|
{
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Done task',
|
||
|
|
'priority' => 'normal',
|
||
|
|
'status' => 'done',
|
||
|
|
]);
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'In progress task',
|
||
|
|
'priority' => 'normal',
|
||
|
|
'status' => 'in_progress',
|
||
|
|
]);
|
||
|
|
Task::create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Pending task',
|
||
|
|
'priority' => 'normal',
|
||
|
|
'status' => 'pending',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$tasks = Task::forWorkspace($this->workspace->id)
|
||
|
|
->orderByStatus()
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$this->assertEquals('In progress task', $tasks[0]->title);
|
||
|
|
$this->assertEquals('Pending task', $tasks[1]->title);
|
||
|
|
$this->assertEquals('Done task', $tasks[2]->title);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// AgentPlan Model Ordering Tests (SQL Injection Prevention)
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_plan_order_by_status_uses_parameterised_query(): void
|
||
|
|
{
|
||
|
|
AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Archived plan',
|
||
|
|
'status' => AgentPlan::STATUS_ARCHIVED,
|
||
|
|
]);
|
||
|
|
AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Active plan',
|
||
|
|
'status' => AgentPlan::STATUS_ACTIVE,
|
||
|
|
]);
|
||
|
|
AgentPlan::factory()->create([
|
||
|
|
'workspace_id' => $this->workspace->id,
|
||
|
|
'title' => 'Draft plan',
|
||
|
|
'status' => AgentPlan::STATUS_DRAFT,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$plans = AgentPlan::forWorkspace($this->workspace->id)
|
||
|
|
->orderByStatus()
|
||
|
|
->get();
|
||
|
|
|
||
|
|
$this->assertEquals('Active plan', $plans[0]->title);
|
||
|
|
$this->assertEquals('Draft plan', $plans[1]->title);
|
||
|
|
$this->assertEquals('Archived plan', $plans[2]->title);
|
||
|
|
}
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Tool Dependencies Tests
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
public function test_state_set_has_workspace_dependency(): void
|
||
|
|
{
|
||
|
|
$tool = new StateSet();
|
||
|
|
$dependencies = $tool->dependencies();
|
||
|
|
|
||
|
|
$this->assertNotEmpty($dependencies);
|
||
|
|
$this->assertEquals('workspace_id', $dependencies[0]->key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_get_has_workspace_dependency(): void
|
||
|
|
{
|
||
|
|
$tool = new StateGet();
|
||
|
|
$dependencies = $tool->dependencies();
|
||
|
|
|
||
|
|
$this->assertNotEmpty($dependencies);
|
||
|
|
$this->assertEquals('workspace_id', $dependencies[0]->key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_state_list_has_workspace_dependency(): void
|
||
|
|
{
|
||
|
|
$tool = new StateList();
|
||
|
|
$dependencies = $tool->dependencies();
|
||
|
|
|
||
|
|
$this->assertNotEmpty($dependencies);
|
||
|
|
$this->assertEquals('workspace_id', $dependencies[0]->key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_plan_get_has_workspace_dependency(): void
|
||
|
|
{
|
||
|
|
$tool = new PlanGet();
|
||
|
|
$dependencies = $tool->dependencies();
|
||
|
|
|
||
|
|
$this->assertNotEmpty($dependencies);
|
||
|
|
$this->assertEquals('workspace_id', $dependencies[0]->key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_plan_list_has_workspace_dependency(): void
|
||
|
|
{
|
||
|
|
$tool = new PlanList();
|
||
|
|
$dependencies = $tool->dependencies();
|
||
|
|
|
||
|
|
$this->assertNotEmpty($dependencies);
|
||
|
|
$this->assertEquals('workspace_id', $dependencies[0]->key);
|
||
|
|
}
|
||
|
|
}
|