Files
sam-api/tests/Security/ApiSecurityTest.php

414 lines
14 KiB
PHP
Raw Normal View History

<?php
namespace Tests\Security;
use App\Models\Model;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ApiSecurityTest extends TestCase
{
use RefreshDatabase;
private User $user;
private User $otherTenantUser;
private Model $model;
protected function setUp(): void
{
parent::setUp();
// Create users in different tenants
$this->user = User::factory()->create(['tenant_id' => 1]);
$this->otherTenantUser = User::factory()->create(['tenant_id' => 2]);
// Create test model
$this->model = Model::factory()->create(['tenant_id' => 1]);
// Set required headers
$this->withHeaders([
'X-API-KEY' => config('app.api_key', 'test-api-key'),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]);
}
/** @test */
public function it_requires_api_key_for_all_endpoints()
{
// Remove API key header
$this->withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]);
// Test various endpoints
$endpoints = [
['GET', '/api/v1/design/models'],
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
];
foreach ($endpoints as [$method, $endpoint]) {
$response = $this->json($method, $endpoint);
$response->assertUnauthorized();
}
}
/** @test */
public function it_rejects_invalid_api_keys()
{
$this->withHeaders([
'X-API-KEY' => 'invalid-api-key',
'Accept' => 'application/json',
]);
$response = $this->getJson('/api/v1/design/models');
$response->assertUnauthorized();
}
/** @test */
public function it_requires_authentication_for_protected_routes()
{
// Test endpoints that require user authentication
$protectedEndpoints = [
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
['POST', "/api/v1/design/models/{$this->model->id}/parameters"],
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
['POST', "/api/v1/design/models/{$this->model->id}/products"],
];
foreach ($protectedEndpoints as [$method, $endpoint]) {
$response = $this->json($method, $endpoint);
$response->assertUnauthorized();
}
}
/** @test */
public function it_enforces_tenant_isolation()
{
// Authenticate as user from tenant 1
Sanctum::actingAs($this->user);
// Try to access model from different tenant
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
$response = $this->getJson("/api/v1/design/models/{$otherTenantModel->id}/parameters");
$response->assertNotFound();
}
/** @test */
public function it_prevents_sql_injection_in_parameters()
{
Sanctum::actingAs($this->user);
// Test SQL injection attempts in various inputs
$sqlInjectionPayloads = [
"'; DROP TABLE models; --",
"' UNION SELECT * FROM users --",
"1' OR '1'='1",
"<script>alert('xss')</script>",
];
foreach ($sqlInjectionPayloads as $payload) {
// Test in BOM resolution parameters
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/bom/resolve", [
'parameters' => [
'W0' => $payload,
'H0' => 800,
'screen_type' => 'SCREEN',
]
]);
// Should either validate and reject, or handle safely
$this->assertTrue(
$response->status() === 422 || $response->status() === 400,
"SQL injection payload was not properly handled: {$payload}"
);
}
}
/** @test */
public function it_sanitizes_formula_expressions()
{
Sanctum::actingAs($this->user);
// Test dangerous expressions that could execute arbitrary code
$dangerousExpressions = [
'system("rm -rf /")',
'eval("malicious code")',
'exec("ls -la")',
'__import__("os").system("pwd")',
'file_get_contents("/etc/passwd")',
];
foreach ($dangerousExpressions as $expression) {
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
'name' => 'test_formula',
'expression' => $expression,
'description' => 'Test formula',
'return_type' => 'NUMBER',
'sort_order' => 1,
]);
// Should reject dangerous expressions
$response->assertStatus(422);
$response->assertJsonValidationErrors(['expression']);
}
}
/** @test */
public function it_prevents_xss_in_user_inputs()
{
Sanctum::actingAs($this->user);
$xssPayloads = [
'<script>alert("xss")</script>',
'javascript:alert("xss")',
'<img src="x" onerror="alert(1)">',
'<svg onload="alert(1)">',
];
foreach ($xssPayloads as $payload) {
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
'name' => 'test_param',
'label' => $payload,
'type' => 'NUMBER',
'default_value' => '0',
'sort_order' => 1,
]);
// Check that XSS payload is not reflected in response
if ($response->isSuccessful()) {
$responseData = $response->json();
$this->assertStringNotContainsString('<script>', json_encode($responseData));
$this->assertStringNotContainsString('javascript:', json_encode($responseData));
$this->assertStringNotContainsString('onerror=', json_encode($responseData));
}
}
}
/** @test */
public function it_rate_limits_api_requests()
{
Sanctum::actingAs($this->user);
$endpoint = "/api/v1/design/models/{$this->model->id}/parameters";
$successfulRequests = 0;
$rateLimitHit = false;
// Make many requests quickly
for ($i = 0; $i < 100; $i++) {
$response = $this->getJson($endpoint);
if ($response->status() === 429) {
$rateLimitHit = true;
break;
}
if ($response->isSuccessful()) {
$successfulRequests++;
}
}
// Should hit rate limit before 100 requests
$this->assertTrue($rateLimitHit || $successfulRequests < 100, 'Rate limiting is not working properly');
}
/** @test */
public function it_validates_file_uploads_securely()
{
Sanctum::actingAs($this->user);
// Test malicious file uploads
$maliciousFiles = [
// PHP script disguised as image
[
'name' => 'image.php.jpg',
'content' => '<?php system($_GET["cmd"]); ?>',
'mime' => 'image/jpeg',
],
// Executable file
[
'name' => 'malware.exe',
'content' => 'MZ...', // PE header
'mime' => 'application/octet-stream',
],
// Script with dangerous extension
[
'name' => 'script.js',
'content' => 'alert("xss")',
'mime' => 'application/javascript',
],
];
foreach ($maliciousFiles as $file) {
$uploadedFile = \Illuminate\Http\UploadedFile::fake()->createWithContent(
$file['name'],
$file['content']
);
$response = $this->postJson('/api/v1/files/upload', [
'file' => $uploadedFile,
'type' => 'model_attachment',
]);
// Should reject malicious files
$this->assertTrue(
$response->status() === 422 || $response->status() === 400,
"Malicious file was not rejected: {$file['name']}"
);
}
}
/** @test */
public function it_prevents_mass_assignment_vulnerabilities()
{
Sanctum::actingAs($this->user);
// Try to mass assign protected fields
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
'name' => 'test_param',
'label' => 'Test Parameter',
'type' => 'NUMBER',
'tenant_id' => 999, // Should not be mass assignable
'created_by' => 999, // Should not be mass assignable
'id' => 999, // Should not be mass assignable
]);
if ($response->isSuccessful()) {
$parameter = $response->json('data');
// These fields should not be affected by mass assignment
$this->assertEquals(1, $parameter['tenant_id']); // Should use authenticated user's tenant
$this->assertEquals($this->user->id, $parameter['created_by']); // Should use authenticated user
$this->assertNotEquals(999, $parameter['id']); // Should be auto-generated
}
}
/** @test */
public function it_handles_concurrent_requests_safely()
{
Sanctum::actingAs($this->user);
// Simulate concurrent creation of parameters
$promises = [];
for ($i = 0; $i < 10; $i++) {
$promises[] = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
'name' => "concurrent_param_{$i}",
'label' => "Concurrent Parameter {$i}",
'type' => 'NUMBER',
'sort_order' => $i,
]);
}
// All requests should be handled without errors
foreach ($promises as $response) {
$this->assertTrue($response->isSuccessful() || $response->status() === 422);
}
// Check for race conditions in database
$parameters = \App\Models\ModelParameter::where('model_id', $this->model->id)->get();
$sortOrders = $parameters->pluck('sort_order')->toArray();
// Should not have duplicate sort orders if handling concurrency properly
$this->assertEquals(count($sortOrders), count(array_unique($sortOrders)));
}
/** @test */
public function it_logs_security_events()
{
// Test that security events are properly logged
$this->withHeaders([
'X-API-KEY' => 'invalid-api-key',
]);
$response = $this->getJson('/api/v1/design/models');
$response->assertUnauthorized();
// Check that failed authentication is logged
$this->assertDatabaseHas('audit_logs', [
'action' => 'authentication_failed',
'ip' => request()->ip(),
]);
}
/** @test */
public function it_protects_against_timing_attacks()
{
// Test that authentication timing is consistent
$validKey = config('app.api_key');
$invalidKey = 'invalid-key-with-same-length-as-valid-key';
$validKeyTimes = [];
$invalidKeyTimes = [];
// Measure timing for valid key
for ($i = 0; $i < 5; $i++) {
$start = microtime(true);
$this->withHeaders(['X-API-KEY' => $validKey])
->getJson('/api/v1/design/models');
$validKeyTimes[] = microtime(true) - $start;
}
// Measure timing for invalid key
for ($i = 0; $i < 5; $i++) {
$start = microtime(true);
$this->withHeaders(['X-API-KEY' => $invalidKey])
->getJson('/api/v1/design/models');
$invalidKeyTimes[] = microtime(true) - $start;
}
$avgValidTime = array_sum($validKeyTimes) / count($validKeyTimes);
$avgInvalidTime = array_sum($invalidKeyTimes) / count($invalidKeyTimes);
// Timing difference should not be significant (within 50ms)
$timingDifference = abs($avgValidTime - $avgInvalidTime);
$this->assertLessThan(0.05, $timingDifference, 'Timing attack vulnerability detected');
}
/** @test */
public function it_validates_formula_complexity()
{
Sanctum::actingAs($this->user);
// Test extremely complex formulas that could cause DoS
$complexFormula = str_repeat('(', 1000) . 'W0' . str_repeat(')', 1000);
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
'name' => 'complex_formula',
'expression' => $complexFormula,
'description' => 'Overly complex formula',
'return_type' => 'NUMBER',
]);
// Should reject overly complex formulas
$response->assertStatus(422);
}
/** @test */
public function it_prevents_path_traversal_attacks()
{
Sanctum::actingAs($this->user);
// Test path traversal in file operations
$pathTraversalPayloads = [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32\\config\\sam',
'%2e%2e%2f%2e%2e%2f%2e%2e%2f',
'....//....//....//etc/passwd',
];
foreach ($pathTraversalPayloads as $payload) {
$response = $this->getJson("/api/v1/files/{$payload}");
// Should not allow path traversal
$this->assertTrue(
$response->status() === 404 || $response->status() === 400,
"Path traversal not prevented for: {$payload}"
);
}
}
}