206 خطوط
7.0 KiB
PHP
206 خطوط
7.0 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\File;
|
|
use App\Models\Meeting;
|
|
use App\Models\MeetingActionItem;
|
|
use App\Models\Permission;
|
|
use App\Models\Project;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\TestCase;
|
|
|
|
class SecurityHardeningTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_login_is_rate_limited(): void
|
|
{
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->postJson('/api/login', [
|
|
'email' => 'missing@example.com',
|
|
'password' => 'wrong-password',
|
|
])->assertUnauthorized();
|
|
}
|
|
|
|
$this->postJson('/api/login', [
|
|
'email' => 'missing@example.com',
|
|
'password' => 'wrong-password',
|
|
])->assertTooManyRequests();
|
|
}
|
|
|
|
public function test_file_upload_rejects_executable_files(): void
|
|
{
|
|
Storage::fake('local');
|
|
|
|
$user = User::factory()->create(['status' => 'active']);
|
|
$this->grantAdminRole($user);
|
|
$project = Project::create([
|
|
'title' => 'Security test project',
|
|
'project_manager_id' => $user->id,
|
|
'created_by' => $user->id,
|
|
]);
|
|
$token = $user->createToken('api-token')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->postJson('/api/files', [
|
|
'project_id' => $project->id,
|
|
'file' => UploadedFile::fake()->create('payload.php', 4, 'application/x-php'),
|
|
])
|
|
->assertUnprocessable();
|
|
}
|
|
|
|
public function test_file_resource_does_not_expose_storage_path(): void
|
|
{
|
|
$user = User::factory()->create(['status' => 'active']);
|
|
$this->grantAdminRole($user);
|
|
$file = File::create([
|
|
'name' => 'safe.pdf',
|
|
'original_name' => 'safe.pdf',
|
|
'path' => 'files/private-safe.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
'size' => 123,
|
|
'fileable_type' => Project::class,
|
|
'fileable_id' => 1,
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
$token = $user->createToken('api-token')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->getJson('/api/files')
|
|
->assertOk()
|
|
->assertJsonMissingPath('data.0.path')
|
|
->assertJsonPath('data.0.download_url', url("/api/files/{$file->id}"));
|
|
}
|
|
|
|
public function test_user_without_permission_cannot_access_user_management(): void
|
|
{
|
|
$user = User::factory()->create(['status' => 'active']);
|
|
$token = $user->createToken('api-token')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->getJson('/api/users')
|
|
->assertForbidden()
|
|
->assertJsonPath('success', false);
|
|
}
|
|
|
|
public function test_project_permission_does_not_expose_unrelated_projects(): void
|
|
{
|
|
$owner = User::factory()->create(['status' => 'active']);
|
|
$viewer = User::factory()->create(['status' => 'active']);
|
|
$permission = Permission::create([
|
|
'name' => 'projects.view',
|
|
'display_name' => 'View projects',
|
|
'guard_name' => 'web',
|
|
'module' => 'projects',
|
|
]);
|
|
$role = Role::create(['name' => 'project-viewer', 'display_name' => 'Project viewer', 'guard_name' => 'web']);
|
|
$role->permissions()->attach($permission);
|
|
$viewer->roles()->attach($role);
|
|
|
|
$foreignProject = Project::create([
|
|
'title' => 'Private project',
|
|
'project_manager_id' => $owner->id,
|
|
'created_by' => $owner->id,
|
|
]);
|
|
|
|
$token = $viewer->createToken('api-token')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->getJson('/api/projects')
|
|
->assertOk()
|
|
->assertJsonPath('meta.total', 0);
|
|
|
|
$this->withToken($token)
|
|
->getJson("/api/projects/{$foreignProject->id}")
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_authenticated_user_cannot_change_another_user_status_without_permission(): void
|
|
{
|
|
$actor = User::factory()->create(['status' => 'active']);
|
|
$target = User::factory()->create(['status' => 'active']);
|
|
$token = $actor->createToken('api-token')->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->patchJson("/api/users/{$target->id}/status", ['status' => 'inactive'])
|
|
->assertForbidden();
|
|
|
|
$this->assertSame('active', $target->fresh()->status);
|
|
}
|
|
|
|
public function test_user_cannot_download_file_from_unrelated_project(): void
|
|
{
|
|
$owner = User::factory()->create(['status' => 'active']);
|
|
$viewer = User::factory()->create(['status' => 'active']);
|
|
$permission = Permission::create([
|
|
'name' => 'files.view',
|
|
'display_name' => 'View files',
|
|
'guard_name' => 'web',
|
|
'module' => 'files',
|
|
]);
|
|
$role = Role::create(['name' => 'file-viewer', 'display_name' => 'File viewer', 'guard_name' => 'web']);
|
|
$role->permissions()->attach($permission);
|
|
$viewer->roles()->attach($role);
|
|
$project = Project::create([
|
|
'title' => 'Private project',
|
|
'project_manager_id' => $owner->id,
|
|
'created_by' => $owner->id,
|
|
]);
|
|
$file = File::create([
|
|
'name' => 'private.pdf',
|
|
'original_name' => 'private.pdf',
|
|
'path' => 'files/private.pdf',
|
|
'mime_type' => 'application/pdf',
|
|
'size' => 10,
|
|
'fileable_type' => Project::class,
|
|
'fileable_id' => $project->id,
|
|
'user_id' => $owner->id,
|
|
]);
|
|
|
|
$token = $viewer->createToken('api-token')->plainTextToken;
|
|
$this->withToken($token)->getJson("/api/files/{$file->id}")->assertForbidden();
|
|
}
|
|
|
|
public function test_meeting_action_item_must_belong_to_meeting_in_route(): void
|
|
{
|
|
$user = User::factory()->create(['status' => 'active']);
|
|
$this->grantAdminRole($user);
|
|
$project = Project::create([
|
|
'title' => 'Meeting security',
|
|
'project_manager_id' => $user->id,
|
|
'created_by' => $user->id,
|
|
]);
|
|
$first = Meeting::create([
|
|
'title' => 'First',
|
|
'project_id' => $project->id,
|
|
'date' => now()->toDateString(),
|
|
'meeting_type' => 'other',
|
|
'created_by' => $user->id,
|
|
]);
|
|
$second = Meeting::create([
|
|
'title' => 'Second',
|
|
'project_id' => $project->id,
|
|
'date' => now()->toDateString(),
|
|
'meeting_type' => 'other',
|
|
'created_by' => $user->id,
|
|
]);
|
|
$item = MeetingActionItem::create([
|
|
'meeting_id' => $second->id,
|
|
'title' => 'Private action',
|
|
]);
|
|
|
|
$token = $user->createToken('api-token')->plainTextToken;
|
|
$this->withToken($token)
|
|
->putJson("/api/meetings/{$first->id}/action-items/{$item->id}", ['title' => 'Tampered'])
|
|
->assertForbidden();
|
|
|
|
$this->assertSame('Private action', $item->fresh()->title);
|
|
}
|
|
}
|