first
This commit is contained in:
265
tests/Feature/Security/Phase6Test.php
Normal file
265
tests/Feature/Security/Phase6Test.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security;
|
||||
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Phase6Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------
|
||||
|
||||
private function makeMp3File(string $name = 'audio.mp3'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'mp3_');
|
||||
// MPEG-1 Layer 3 frame sync word: FF FB = 11111111 11111011
|
||||
// Followed by header byte 0x90 (128kbps, 44100Hz) and frame data
|
||||
file_put_contents($tmpPath, "\xFF\xFB\x90\x00" . str_repeat("\xFF", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/mpeg', null, true)];
|
||||
}
|
||||
|
||||
private function makeWavFile(string $name = 'audio.wav'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'wav_');
|
||||
// WAV: RIFF header 0x52 0x49 0x46 0x46
|
||||
file_put_contents($tmpPath, "\x52\x49\x46\x46\x00\x00\x00\x00\x57\x41\x56\x45" . str_repeat("\x00", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/wav', null, true)];
|
||||
}
|
||||
|
||||
private function makeInvalidFile(string $name = 'malicious.mp3'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'fake_');
|
||||
file_put_contents($tmpPath, "<?php echo 'malicious'; ?>" . str_repeat("X", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/mpeg', null, true)];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Security Headers
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_security_headers_present_on_web_response(): void
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
$response->assertHeader('X-XSS-Protection', '1; mode=block');
|
||||
$response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
|
||||
public function test_csp_and_permissions_headers_present(): void
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertNotEmpty($response->headers->get('Content-Security-Policy'));
|
||||
$this->assertNotEmpty($response->headers->get('Permissions-Policy'));
|
||||
}
|
||||
|
||||
public function test_security_headers_present_on_authenticated_response(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('user.dashboard'));
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
public function test_csp_blocks_framing_from_other_origins(): void
|
||||
{
|
||||
$csp = $this->get(route('login'))->headers->get('Content-Security-Policy');
|
||||
|
||||
$this->assertStringContainsString("frame-ancestors", $csp === null ? '' : $csp);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Magic Bytes Validation
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_upload_accepts_valid_mp3_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeMp3File();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Audio MP3 Sah',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
Queue::assertPushed(\App\Jobs\TranscribeAudioJob::class);
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_accepts_valid_wav_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeWavFile();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Audio WAV Sah',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_rejects_file_with_invalid_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeInvalidFile();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Fail Palsu',
|
||||
'audio' => $file,
|
||||
])->assertSessionHasErrors('audio');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Storage::disk('private')->assertDirectoryEmpty('transcriptions');
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_rejects_polyglot_file_with_valid_extension_wrong_content(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'poly_');
|
||||
// HTML disguised as MP3
|
||||
file_put_contents($tmpPath, '<html><body>XSS</body></html>' . str_repeat('X', 512));
|
||||
$file = new UploadedFile($tmpPath, 'audio.mp3', 'audio/mpeg', null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Polyglot',
|
||||
'audio' => $file,
|
||||
])->assertSessionHasErrors('audio');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Rate Limiting — Login (5 per minute)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_login_throttled_after_5_failed_attempts(): void
|
||||
{
|
||||
User::factory()->create(['email' => 'pengguna@test.com']);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/login', [
|
||||
'email' => 'pengguna@test.com',
|
||||
'password' => 'kata-laluan-salah',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => 'pengguna@test.com',
|
||||
'password' => 'kata-laluan-salah',
|
||||
])->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_login_allows_successful_login_within_limit(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'sah@test.com',
|
||||
'password' => bcrypt('kata-laluan-betul'),
|
||||
]);
|
||||
|
||||
// 2 failed attempts
|
||||
$this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']);
|
||||
$this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']);
|
||||
|
||||
// Successful login still works under limit
|
||||
$this->post('/login', [
|
||||
'email' => 'sah@test.com',
|
||||
'password' => 'kata-laluan-betul',
|
||||
])->assertRedirect();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Rate Limiting — Upload (10 per hour)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_upload_throttled_after_10_requests(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 10 requests — will fail validation (no valid magic bytes) but throttle still counts
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->actingAs($user)->post(route('user.projects.store'), [
|
||||
'title' => "Projek {$i}",
|
||||
'audio' => UploadedFile::fake()->create("audio-{$i}.mp3", 1, 'audio/mpeg'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 11th request must be throttled regardless of payload
|
||||
$this->actingAs($user)->post(route('user.projects.store'), [
|
||||
'title' => 'Projek 11',
|
||||
'audio' => UploadedFile::fake()->create('audio.mp3', 1, 'audio/mpeg'),
|
||||
])->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_upload_throttle_is_per_user(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
// Exhaust userA's quota
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->actingAs($userA)->post(route('user.projects.store'), [
|
||||
'title' => "A-{$i}",
|
||||
'audio' => UploadedFile::fake()->create("a-{$i}.mp3", 1, 'audio/mpeg'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($userA)->post(route('user.projects.store'), [
|
||||
'title' => 'A-11',
|
||||
'audio' => UploadedFile::fake()->create('a.mp3', 1, 'audio/mpeg'),
|
||||
])->assertStatus(429);
|
||||
|
||||
// userB's quota is independent — not throttled
|
||||
[$tmpPath, $file] = $this->makeMp3File('b.mp3');
|
||||
$this->actingAs($userB)->post(route('user.projects.store'), [
|
||||
'title' => 'B-1',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user