" . 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, 'XSS' . 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); } }