Hình dung MockMvc như một buổi tập dượt trên sân khấu dựng tạm. Cảnh trí tầng MVC được dựng lại rất thật — controller, ánh xạ, chuỗi lọc Security — nên phần lớn cảnh diễn trơn tru. Nhưng quanh cái sân khấu đó không có toà nhà thật (container servlet) và không có khán giả thật (trình duyệt). Vài hiệu ứng chỉ hỏng khi có hai thứ đó. Test bảo mật xanh không có nghĩa ứng dụng an toàn, và bài này đo chính xác khoảng cách đó.

Cùng một request, hai kết quả

  MockMvc  GET  /admin/xem (vai USER)  -> 403
  MockMvc  POST /admin/xoa (vai USER)  -> 403

  máy chủ thật  GET  /admin/xem        -> 403
  máy chủ thật  POST /admin/xoa        -> 405

Test xanh, sản xuất trả 405 Method Not Allowed cho một request đáng lẽ phải là 403.

Nguyên nhân nằm ở một chi tiết:

.exceptionHandling(e -> e.accessDeniedPage("/error/403"))

@GetMapping("/error/403")        // <- chỉ GET
String batGet(){ return "403-page"; }

Spring Security forward sang trang lỗi và giữ nguyên method của request gốc. POST bị từ chối sẽ forward thành một POST tới /error/403, mà chỗ đó chỉ khai @GetMapping — nên Spring MVC trả 405.

Cách chữa là một chữ:

@RequestMapping("/error/403")    // không phải @GetMapping
MockMvc không tái hiện được vì nó không thực hiện forward của container servlet theo cùng cách. Nó dừng ở mã trạng thái mà Security đặt ra, chưa đi qua bước dispatch thứ hai — đúng bước đã đo ở bài 17 và bài 42.

Ba loại lỗi MockMvc không bắt được

Forward tới trang lỗi, như trên.

Lỗi multipart. Bài 19 đã nói: MockMultipartFile không đi qua bộ phân tích multipart thật, nên MaxUploadSizeExceededException không bao giờ phát sinh trong test. Chạy thật thì 413 với thân rỗng.

Script nội tuyến bị CSP chặn. Bài 45: MockMvc không thực thi JavaScript, nên mọi <script> viết thẳng trong template vẫn "chạy" trong test và chết trên trình duyệt.

Mạch chung: MockMvc mô phỏng tầng MVC, không mô phỏng container servlet và không mô phỏng trình duyệt. Ba loại lỗi trên đều nằm ở hai tầng nó không có — toà nhà và khán giả mà sân khấu tập không dựng.

Cách bù: một nhúm test dùng @SpringBootTest(webEnvironment = RANDOM_PORT) với TestRestTemplate hoặc WebTestClient — chúng đi qua Tomcat thật.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ThatSuChayTest {
    @Autowired TestRestTemplate rest;

    @Test void postBiTuChoiTra403KhongPhai405() {
        var r = rest.withBasicAuth("an", "m").postForEntity("/admin/xoa", null, String.class);
        assertThat(r.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }
}

Không cần nhiều — vài chục test loại này đủ phủ những chỗ MockMvc mù.

spring-security-test

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-test</artifactId>
  <scope>test</scope>
</dependency>
@Test @WithMockUser(roles = "USER")
void nguoiThuongKhongVaoDuocAdmin() throws Exception {
    mvc.perform(get("/admin/xem")).andExpect(status().isForbidden());
}

@Test @WithAnonymousUser
void khachPhaiDangNhap() throws Exception {
    mvc.perform(get("/rieng")).andExpect(status().isUnauthorized());
}

Chú ý @WithMockUser(roles = "USER") tạo quyền ROLE_USER — nó tự thêm tiền tố, đúng như hasRole ở bài 41. Muốn quyền không có tiền tố thì dùng authorities = "...".

Với @AuthenticationPrincipal là kiểu riêng của bạn, @WithMockUser không đủ — dùng @WithUserDetails("an") để nạp qua UserDetailsService thật, hoặc viết một annotation riêng.

Cho request cần CSRF (bài 42):

mvc.perform(post("/web/ghi").with(csrf()))

Và đừng dùng .with(csrf()) ở mọi chỗ theo phản xạ — hãy có ít nhất một test khẳng định request thiếu token bị từ chối. Nếu không, bạn có thể tắt CSRF mà cả bộ test vẫn xanh.

Bốn test bảo mật đáng viết

Mỗi endpoint được bảo vệ có một test cho vai sai. Nghe hiển nhiên, nhưng thực tế phần lớn dự án chỉ test đường đi đúng. Một ổ khoá bạn chưa bao giờ thử cạy là một ổ khoá bạn chỉ đoán là nó giữ.

Test tham chiếu đối tượng. Đây là loại có giá trị cao nhất, vì nó bắt lỗ hổng số một ở bài 46:

@Test @WithMockUser("nguoi-b")
void khongDocDuocDonCuaNguoiKhac() throws Exception {
    Long idCuaA = taoDonCho("nguoi-a");
    mvc.perform(get("/don/" + idCuaA)).andExpect(status().isNotFound());
}

Test gán thuộc tính hàng loạt:

@Test @WithMockUser("an")
void khongTuNangQuyenDuoc() throws Exception {
    mvc.perform(post("/ho-so").contentType(JSON)
            .content("{\"ten\":\"x\",\"vai\":\"ADMIN\"}"));
    assertThat(repo.findByTen("an").getVai()).isEqualTo(Vai.USER);
}

Test rằng chỗ nhạy cảm không lộ ra:

@Test void actuatorKhongLoEnv() throws Exception {
    mvc.perform(get("/actuator/env")).andExpect(status().isNotFound());
}

Blog này có một test tương tự cho log — AdminPasswordNotLoggedTest bắt log thật và khẳng định mật khẩu quản trị không xuất hiện trong đó.

Quét tự động trong CI

Ba thứ chạy được không cần chuyên gia:

Quét phụ thuộc. Bài 46 và bài 99 sê-ri Java: một lệnh Docker, và nhớ đặt ngưỡng làm đỏ build — báo cáo không ai đọc thì không bảo vệ được gì.

Phân tích tĩnh. SpotBugs với find-sec-bugs bắt được SQL injection nối chuỗi, Random dùng cho token, đường dẫn không kiểm.

Quét động. OWASP ZAP ở chế độ baseline chạy được trong CI và tìm header thiếu, cookie không có cờ, trang lộ thông tin.

Không cái nào thay được đọc mã, nhưng cả ba đều rẻ và chạy mỗi lần build.

Ba điều về test bảo mật

Test cả đường đi sai. Test bảo mật chỉ kiểm "người đúng vào được" là không kiểm gì cả.

Đừng tắt bảo mật trong test. Thấy @AutoConfigureMockMvc(addFilters = false) nghĩa là toàn bộ chuỗi lọc bị bỏ, và mọi test sau đó không nói gì về bảo mật. Nó có chỗ dùng — test riêng logic controller — nhưng đừng để nó thành mặc định.

Test cấu hình sản xuất, không phải cấu hình test. Profile test thường tắt HTTPS, nới CSP, mở Actuator. Có ít nhất một test chạy với cấu hình thật.

Nếu chỉ làm một việc sau bài này, lấy một test bảo mật của bạn và đảo ngược khẳng định — đổi isForbidden() thành isOk(). Test vẫn xanh nghĩa là nó chưa bao giờ kiểm điều bạn nghĩ. Tôi đã gặp chuyện này với một test dùng .andExpect(status().is4xxClientError()) — nó xanh cả khi mã trả về là 405 thay vì 403.

Mẫu số chung

Bài học nền ở đây không riêng cho bảo mật hay cho Spring: một bài test chỉ đáng tin đúng bằng mức nó tái hiện trung thực cái nó thay thế, và lỗi dồn đúng vào những đường nối mà cái thay thế bỏ qua. MockMvc bỏ qua container và trình duyệt; và mọi hệ đều có cặp "test nhanh nhưng không trung thực" đấu với "test chậm nhưng thật".

  • CSDL trong bộ nhớ (H2) so với PostgreSQL thật — chính CLAUDE.md của blog ghi cast(... as date) trả kiểu khác nhau giữa hai bên; Testcontainers sinh ra để chạy CSDL thật đúng vì lẽ đó.
  • API giả (WireMock) so với môi trường thật; DOM không đầu (jsdom) so với trình duyệt thật (Playwright).

Nên mọi hệ sinh thái đều mọc ra một tầng "chạy bằng đồ thật" (Testcontainers, Playwright, môi trường phù du) nhắm đúng vào lớp lỗi mà tầng nhanh bị mù. Và điều thứ hai, đúng ở mọi nơi nhưng gắt nhất với bảo mật: một test chỉ khẳng định đường đi đúng thì không khẳng định gì cả — phải khẳng định cả vế phủ định (vai sai bị chặn, thiếu token bị từ chối, bản ghi người khác bị giấu). Mẹo đảo-ngược-khẳng-định ở trên chính là kiểm tra xem một test có khả năng đỏ hay không (họ hàng với kiểm thử đột biến). Sợi chỉ chung: biết cái test double của bạn lặng lẽ bỏ qua lớp nào rồi phủ những đường nối đó bằng vài test đồ thật; và với bất cứ thứ gì có nhiệm vụ chặn — xác thực, kiểm tra, một người gác — hãy viết cái test cố vượt qua nó, rồi chứng minh test đó có thể đỏ.

Ngày mai: chốt chặng bảo mật — mười bài, và danh sách kiểm rút ra từ chúng.