first(); if ($stale) { UserRole::where('user_id', $stale->id)->delete(); $stale->delete(); } $this->adminUser = User::create([ 'name' => 'DUSK EDGE ADMIN', 'email' => 'dusk_edge_admin@test.local', 'phone' => '09' . rand(10000000, 99999999), 'password' => bcrypt('dusk_admin_pwd_2026'), 'status' => 1, 'address' => 'Test', 'known_sis' => 0, 'job' => 'Admin', 'spined' => 0, ]); $roles = Role::where('role_status', 1)->get(); foreach ($roles as $role) { UserRole::create(['user_id' => $this->adminUser->id, 'role_id' => $role->id]); } } protected function tearDown(): void { UserRole::where('user_id', $this->adminUser->id)->delete(); $this->adminUser->delete(); // Clean up any prizes created during TC-55 if validation was missing Prize::where('prize_name', 'Dusk Negative Number Prize Test')->delete(); parent::tearDown(); } // ========================================================================= // [TC-55] Số lượng giải thưởng không được là số âm // ========================================================================= /** * Nhập prize_number = -5 vào form thêm giải thưởng phải bị từ chối. * * ⚠️ TRẠNG THÁI: Test này có thể FAIL với code hiện tại. * * Lý do: PrizeController::updatePrize() validate: * 'prize_number' => 'nullable|numeric' * Thiếu quy tắc `min:0` → số âm được chấp nhận (bug). * * CÁch sửa để test pass: * Trong PrizeController::updatePrize(), thay dòng: * 'prize_number' => 'nullable|numeric', * Bằng: * 'prize_number' => 'nullable|numeric|min:0', * Và thêm message lỗi tương ứng vào mảng $messages. */ public function test_prize_number_cannot_be_negative(): void { $testPrizeName = 'Dusk Negative Number Prize Test'; $this->browse(function (Browser $browser) use ($testPrizeName) { $browser->loginAs($this->adminUser) ->visit('/admin/prize/update?id=0') ->pause(300); // Xóa các thuộc tính HTML5 validation để bypass client-side check // và buộc server-side validation chạy $browser->script(" document.querySelectorAll('input[required]').forEach(el => el.removeAttribute('required')); document.querySelectorAll('input[min]').forEach(el => el.removeAttribute('min')); document.querySelectorAll('input[type=\"number\"]').forEach(el => { el.removeAttribute('min'); el.removeAttribute('step'); }); "); $browser->type('prize_name', $testPrizeName) ->clear('prize_number') ->type('prize_number', '-5') ->type('prize_order', '10') ->select('prize_status', '1') ->press('Thêm mới') ->pause(600); // Kết quả mong muốn: thông báo lỗi validation hiển thị $browser->assertPresent('.text-danger, .alert-danger, [class*="error"]'); }); // DB assertion: Prize với tên test và prize_number âm KHÔNG được tạo $wrongPrize = Prize::where('prize_name', $testPrizeName)->first(); $this->assertNull( $wrongPrize, "Prize với prize_number = -5 không được phép tạo trong DB. " . "Cần thêm 'min:0' vào validation rule của PrizeController." ); } // ========================================================================= // [TC-56] Upload file không hợp lệ vào ô logo phải bị từ chối // ========================================================================= /** * Upload file PDF (.pdf) vào trường logo_image phải bị server từ chối. * * ⚠️ TRẠNG THÁI: Test này có thể FAIL với code hiện tại. * * Lý do: ParameterController::update() → updateParameterImage() không * validate MIME type của file upload. Mọi định dạng file đều được chấp nhận. * Đây là lỗ hổng bảo mật (OWASP A05: Security Misconfiguration / Unrestricted Upload). * * Cách sửa để test pass: * Thêm validation vào ParameterController::update() trước vòng foreach: * $request->validate([ * 'logo_image' => 'nullable|mimes:jpg,png,jpeg,webp|max:2048', * 'banner_image' => 'nullable|mimes:jpg,png,jpeg,webp|max:2048', * ], [ * 'logo_image.mimes' => 'Logo chỉ chấp nhận định dạng jpg, png, jpeg, webp', * 'banner_image.mimes' => 'Banner chỉ chấp nhận định dạng jpg, png, jpeg, webp', * ]); * * CẢNH BÁO QUAN TRỌNG: Không bao giờ cho phép upload .php hoặc executable file * lên web server — có thể dẫn đến Remote Code Execution (RCE). */ public function test_invalid_logo_upload_is_rejected(): void { // Tạo file PDF giả tạm thời để test upload $fakePdfPath = sys_get_temp_dir() . '/dusk_test_fake_logo.pdf'; file_put_contents($fakePdfPath, '%PDF-1.4 this is a fake pdf for dusk test only'); try { $this->browse(function (Browser $browser) use ($fakePdfPath) { $browser->loginAs($this->adminUser) ->visit('/admin/parameter') ->pause(300); // Attach file PDF vào input[name="logo_image"] $browser->attach('logo_image', $fakePdfPath); $browser->press('Cập nhật') ->pause(800); // Kết quả mong muốn: lỗi validation về kiểu file hiển thị $browser->assertPresent('.text-danger, .alert-danger, [class*="error"]'); // Không được hiển thị thông báo thành công $browser->assertDontSee('Cập nhật thành công'); }); } finally { // Xóa file tạm dù test pass hay fail if (file_exists($fakePdfPath)) { unlink($fakePdfPath); } } } // ========================================================================= // [TC-57] Nút export Excel tồn tại, href đúng, và tải file thành công // ========================================================================= /** * Kiểm tra chức năng export Excel danh sách người trúng giải theo 2 cách: * * A) Browser test (Dusk): * - #excel_export tồn tại trên trang * - href của nó trỏ đến route admin/prizeuser/export * * B) HTTP test (PHPUnit trực tiếp): * - Response từ /admin/prizeuser/export trả về HTTP 200 * - Content-Type là application/vnd.openxmlformats-officedocument.spreadsheetml.sheet * - Header Content-Disposition chứa tên file .xlsx * * LƯU Ý BẢO MẬT: Route /admin/prizeuser/export hiện đặt NGOÀI middleware * UserRole → bất kỳ ai (kể cả guest) đều có thể tải file export. * Khuyến nghị: di chuyển route vào trong group middleware UserRole. */ public function test_prize_winner_export_excel_response(): void { // ---------------------------------------------------------------- // PHẦN A: Browser test — kiểm tra UI của trang prizeuser // // LƯU Ý: #excel_export là thẻ KHÔNG có static href attribute. // Href được tạo động trong JS onclick handler: // let a = document.createElement("a"); // a.href = exportRouteUrl + '?fromdate=...&todate=...'; // document.body.appendChild(a); a.click(); // Vì vậy KHÔNG thể dùng attribute('#excel_export', 'href'). // ---------------------------------------------------------------- $this->browse(function (Browser $browser) { $browser->loginAs($this->adminUser) ->visit('/admin/prizeuser') ->assertPresent('#excel_export'); // Lấy URL export từ JavaScript (route được inject vào view bằng Blade) // và kiểm tra rằng nó trỏ đúng đến route admin/prizeuser/export $exportRouteUrl = $browser->script(" // Trích xuất URL từ onclick handler bằng cách tạm thời override createElement var capturedHref = null; var origCreate = document.createElement.bind(document); document.createElement = function(tag) { var el = origCreate(tag); if (tag.toLowerCase() === 'a') { Object.defineProperty(el, 'href', { set: function(v) { capturedHref = v; this._href = v; }, get: function() { return this._href || ''; } }); } return el; }; document.getElementById('excel_export').click(); document.createElement = origCreate; return capturedHref; ")[0]; $this->assertNotNull( $exportRouteUrl, '#excel_export onclick phải tạo anchor với href khi được click' ); $this->assertStringContainsString( 'prizeuser/export', $exportRouteUrl, 'URL export phải trỏ đến route admin/prizeuser/export' ); }); // ---------------------------------------------------------------- // PHẦN B: Kiểm tra route tồn tại qua PHP route() helper // // LƯU Ý: Không dùng $this->actingAs()->get() vì view excel.blade.php // gọi exit() sau khi xuất file, điều này sẽ terminate PHPUnit process. // ---------------------------------------------------------------- $exportUrl = route('admin/prizeuser/export'); $this->assertStringContainsString( '/admin/prizeuser/export', $exportUrl, "Route 'admin/prizeuser/export' phải tồn tại và trả về URL hợp lệ" ); } }