본문 바로가기
개발/Spring

Spring security Test

by BellOne4222 2024. 2. 28.

Spring security Test

  • SpringSecurity의 테스트에서는 User가 로그인한 상태를 가정하고 테스트해야 하는 경우가 많다.
  • 인증을 받지 않은 상태로 테스트를 하면 SpringSecurity에서 요청 자체를 막기 때문에 테스트가 제대로 동작조차 하지 못하기 때문에 spring-security-test를 사용해서 해결
  • Spring-security-test를 사용하면 테스트 직전에 Mock User를 인증시켜놓고 테스트를 구동시킬수 있다.

 

의존성 추가

testImplementation 'org.springframework.security:spring-security-test'

 

mockMVC

  • Test 실행 전 MockMvc에 springSecurity (static 메소드)를 설정
@BeforeEach
public void setUp(@Autowired WebApplicationContext applicationContext) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext) 
            .apply(springSecurity())
            .build();

 

BDDAssertions

  • then절로 테스트 결과를 검증

 

MockMvc

  • perform
    • 요청을 전송하는 역할
    • 결과로 ResultActions 반환
  • get, post, put, delete
    • 요청할 http method 를 perform()안에 넣어서 결정, 인자로 경로를 사용
    • perform(get("/hello"))
  • params
    • Key value 파라미터를 전달
    • 여러개 일때는 params, 단일은 param
  • andExpect
    • 응답을 검증
    • andExpect(status().isBadRequest())
    • status()
      • 상태 검증
      • isOk(200)
    • view() 
      • 응답으로 받은 뷰 이름 검증
    • redirect()
      • 응답으로 받은 redirect 검증
    • content()
      • 응답 body 검증
  • andDo
    • 해야할 일 표현
    • andDo(print()) : 결과를 출력

 

가짜 유저 세팅하여 테스트하는 방법

  • @WithMockUser
    • 특정 사용자가 존재하는 것처럼 테스트 진행
    • Mock(가짜) User를 생성하고 Authentication을 만든다.
    • User : org.springframework.security.core.userdetails.User
    • 내부에서 UserDetails를 직접 구현해서 Custom User를 만들어 사용하는 경우에는 WithMockUser를 사용하면 문제가 발생할 수 있다.
      • WithMockUser는 org.springframework.security.core.userdetails.User를 만들어 주지만 우리가 필요한 User는 Custom User이기 때문에 인증에는 문제가 없지 형변환 에러(class cast)가 발생 할 수도 있다.

 

  • @WithUserDetails
    • 테스트를 위해 별도로 구현한 UserDetailsService를 참고해서 사용자를 가짜로 로그인 할 수 있다.
    • WithMockUser와 마찬가지로 Mock(가짜) User를 생성하고 Authentication을 생성
    • 가짜 User를 가져올 때 UserDetailsService의 Bean 이름을 넣어줘서userDetailsService.loadUserByUsername(String username)을 통해 User를 가져온다.
  • @WithAnonymousUser
    • WithMockUser와 동일하지만 인증된 유저 대신에 익명(Anonymous)유저를 Authentication에서 사용
    • 익명이기 때문에 멤버변수에 유저와 관련된 값이 없다.
  • @WithSecurityContext
    • 다른 방식들은 Authentication을 가짜로 만들었다고 한다면 WithSecurityContext는 아예 SecurityContext를 만든다.
    • WithSecurityContextFactory를 Implement한 Class를 넣어준다
public interface WithSecurityContextFactory<A extends Annotation> {
SecurityContext createSecurityContext(A annotation); 
}

 

  • with(user(   ))
    • 다른 방식은 어노테이션 기반인 반면에 이 방식은 직접 User를 MockMvc에 주입하는 방법
    • WithMockUser와 마찬가지로 유저를 생성해서 Principal에 넣고 Authentication을 생성
    • org.springframework.security.test.web.servlet.request.user를 사용
mockMvc.perform(get("/admin")
                .with(user(user))) // 유저 추가

 

// AdminControllerTest

@SpringBootTest // 통합 테스트를 위한 애플리케이션 컨텍스트를 로드합니다.
@ActiveProfiles(profiles = "test") //  "test" 프로파일이 활성화되도록 설정
@Transactional // 각각의 테스트 메소드 실행 후 롤백하여 데이터베이스의 상태를 변경하지 않고 테스트를 수행
class AdminControllerTest {

	@Autowired // 스프링에 의해 자동 주입되는 필드를 나타냅니다. 이 경우에는 userRepository가 자동으로 주입됩니다.
	private UserRepository userRepository;
	private MockMvc mockMvc;
	private User user;
	private User admin;

	@BeforeEach // 각 테스트 메소드가 실행되기 전에 실행되는 메소드입니다. 여기서는 테스트에 필요한 MockMvc 객체를 설정하고, 테스트용 사용자 데이터를 데이터베이스에 저장합니다.
	public void setUp(@Autowired WebApplicationContext applicationContext) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
			.apply(SecurityMockMvcConfigurers.springSecurity()) // Spring Security 설정 적용
			.alwaysDo(print())
			.build();
		// ROLE_USER 권한이 있는 유저 생성
		user = userRepository.save(new User("user", "user", "ROLE_USER"));
		// ROLE_ADMIN 권한이 있는 관리자 생성
		admin = userRepository.save(new User("admin", "admin", "ROLE_ADMIN"));
	}

	// 특정 URL에 대한 테스트 케이스: 인증 없이 접근하는 경우
	@Test
	void getNoteForAdmin_인증없음() throws Exception {
		mockMvc.perform(get("/admin").with(csrf())) // CSRF 토큰 추가
			.andExpect(redirectedUrlPattern("**/login")) // 로그인 페이지로 리다이렉션되는지 확인
			.andExpect(status().is3xxRedirection()); // 3xx 리다이렉션 상태코드 반환 여부 확인
	}

	// 특정 URL에 대한 테스트 케이스: 어드민 권한으로 접근하는 경우
	@Test
	void getNoteForAdmin_어드민인증있음() throws Exception {
		mockMvc.perform(get("/admin").with(csrf()).with(user(admin))) // 어드민 유저로 요청
			.andExpect(status().is2xxSuccessful()); // 2xx 성공 상태코드 반환 여부 확인
	}

	// 특정 URL에 대한 테스트 케이스: 유저 권한으로 접근하는 경우
	@Test
	void getNoteForAdmin_유저인증있음() throws Exception {
		mockMvc.perform(get("/admin").with(csrf()).with(user(user))) // 유저로 요청
			.andExpect(status().isForbidden()); // 403 Forbidden 상태코드 반환 여부 확인
	}
}

 

// NoteControllerTest

@SpringBootTest
@ActiveProfiles(profiles = "test")
@Transactional
class NoteControllerTest {

	@Autowired
	private UserRepository userRepository;
	@Autowired
	private NoteRepository noteRepository;
	private MockMvc mockMvc;
	private User user;
	private User admin;

	@BeforeEach
	public void setUp(@Autowired WebApplicationContext applicationContext) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
			.apply(springSecurity())
			.alwaysDo(print())
			.build();
		// 유저와 어드민 데이터 준비
		user = userRepository.save(new User("user123", "user", "ROLE_USER"));
		admin = userRepository.save(new User("admin123", "admin", "ROLE_ADMIN"));
	}

	// 인증 없이 노트에 접근하는 경우를 테스트합니다.
	@Test
	void getNote_인증없음() throws Exception {
		mockMvc.perform(get("/note"))
			.andExpect(redirectedUrlPattern("**/login"))
			.andExpect(status().is3xxRedirection());
	}

	// 인증된 사용자가 노트에 접근하는 경우를 테스트합니다.
	@Test
	@WithUserDetails(
		value = "user123", // 사용자명
		userDetailsServiceBeanName = "userDetailsService", // UserDetailsService 구현체의 Bean
		setupBefore = TestExecutionEvent.TEST_EXECUTION // 테스트 실행 직전에 유저를 가져온다.
	)
	void getNote_인증있음() throws Exception {
		mockMvc.perform(
				get("/note")
			).andExpect(status().isOk()) // 성공적인 응답 여부 확인
			.andExpect(view().name("note/index")) // 정상적인 뷰 반환 여부 확인
			.andDo(print()); // 결과를 출력
	}

	// 인증 없이 노트를 작성하는 경우를 테스트합니다.
	@Test
	void postNote_인증없음() throws Exception {
		mockMvc.perform(
				post("/note").with(csrf())
					.contentType(MediaType.APPLICATION_FORM_URLENCODED)
					.param("title", "제목")
					.param("content", "내용")
			).andExpect(redirectedUrlPattern("**/login")) // 로그인 페이지로 리다이렉션 여부 확인
			.andExpect(status().is3xxRedirection()); // 3xx 리다이렉션 상태코드 반환 여부 확인
	}

	// 어드민 권한으로 노트를 작성하는 경우를 테스트합니다.
	@Test
	@WithUserDetails(
		value = "admin123",
		userDetailsServiceBeanName = "userDetailsService",
		setupBefore = TestExecutionEvent.TEST_EXECUTION
	)
	void postNote_어드민인증있음() throws Exception {
		mockMvc.perform(
			post("/note").with(csrf())
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.param("title", "제목")
				.param("content", "내용")
		).andExpect(status().isForbidden()); // 접근 거부
	}

	// 유저 권한으로 노트를 작성하는 경우를 테스트합니다.
	@Test
	@WithUserDetails(
		value = "user123",
		userDetailsServiceBeanName = "userDetailsService",
		setupBefore = TestExecutionEvent.TEST_EXECUTION
	)
	void postNote_유저인증있음() throws Exception {
		mockMvc.perform(
			post("/note").with(csrf())
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.param("title", "제목")
				.param("content", "내용")
		).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
	}

	// 인증 없이 노트를 삭제하는 경우를 테스트합니다.
	@Test
	void deleteNote_인증없음() throws Exception {
		Note note = noteRepository.save(new Note("제목", "내용", user));
		mockMvc.perform(
				delete("/note?id=" + note.getId()).with(csrf())
			).andExpect(redirectedUrlPattern("**/login"))
			.andExpect(status().is3xxRedirection());
	}

	// 유저 권한으로 노트를 삭제하는 경우를 테스트합니다.
	@Test
	@WithUserDetails(
		value = "user123",
		userDetailsServiceBeanName = "userDetailsService",
		setupBefore = TestExecutionEvent.TEST_EXECUTION
	)
	void deleteNote_유저인증있음() throws Exception {
		Note note = noteRepository.save(new Note("제목", "내용", user));
		mockMvc.perform(
			delete("/note?id=" + note.getId()).with(csrf())
		).andExpect(redirectedUrl("note")).andExpect(status().is3xxRedirection());
	}

	// 어드민 권한으로 노트를 삭제하는 경우를 테스트합니다.
	@Test
	@WithUserDetails(
		value = "admin123",
		userDetailsServiceBeanName = "userDetailsService",
		setupBefore = TestExecutionEvent.TEST_EXECUTION
	)
	void deleteNote_어드민인증있음() throws Exception {
		Note note = noteRepository.save(new Note("제목", "내용", user));
		mockMvc.perform(
			delete("/note?id=" + note.getId()).with(csrf()).with(user(admin))
		).andExpect(status().isForbidden()); // 접근 거부
	}
}

 

// NoteServiceTest

@SpringBootTest // 스프링 부트 애플리케이션 컨텍스트를 로드하여 테스트 환경을 설정합니다.
@ActiveProfiles(profiles = "test") // "test" 프로파일을 활성화하여 테스트 시 환경설정을 구성합니다.
@Transactional // 각 테스트 메소드가 실행된 후 롤백되어 테스트 간 데이터 오염을 방지합니다.
class NoteServiceTest {

	@Autowired
	private NoteService noteService; // 테스트할 노트 서비스를 주입합니다.
	@Autowired
	private UserRepository userRepository; // 사용자 레포지토리를 주입합니다.
	@Autowired
	private NoteRepository noteRepository; // 노트 레포지토리를 주입합니다.

	// 사용자가 게시한 노트를 조회하는 경우를 테스트합니다.
	@Test
	void findByUser_유저가_게시물조회() {
		// given
		User user = userRepository.save(new User("username", "password", "ROLE_USER"));
		noteRepository.save(new Note("title1", "content1", user));
		noteRepository.save(new Note("title2", "content2", user));
		// when
		List<Note> notes = noteService.findByUser(user);
		// then
		then(notes.size()).isEqualTo(2); // 사용자에 의해 생성된 노트 수를 확인
		Note note1 = notes.get(0);
		Note note2 = notes.get(1);

		// note1 = title2
		then(note1.getUser().getUsername()).isEqualTo("username");
		then(note1.getTitle()).isEqualTo("title2"); // 최신 노트가 먼저 조회되어야 함
		then(note1.getContent()).isEqualTo("content2");
		// note2 = title1
		then(note2.getUser().getUsername()).isEqualTo("username");
		then(note2.getTitle()).isEqualTo("title1");
		then(note2.getContent()).isEqualTo("content1");
	}

	// 어드민이 모든 사용자의 게시물을 조회하는 경우를 테스트합니다.
	@Test
	void findByUser_어드민이_조회() {
		// given
		User admin = userRepository.save(new User("admin", "password", "ROLE_ADMIN"));
		User user1 = userRepository.save(new User("username", "password", "ROLE_USER"));
		User user2 = userRepository.save(new User("username2", "password", "ROLE_USER"));
		noteRepository.save(new Note("title1", "content1", user1));
		noteRepository.save(new Note("title2", "content2", user1));
		noteRepository.save(new Note("title3", "content3", user2));
		// when
		List<Note> notes = noteService.findByUser(admin);
		// then
		then(notes.size()).isEqualTo(3); // 모든 노트를 조회해야 함
		Note note1 = notes.get(0);
		Note note2 = notes.get(1);
		Note note3 = notes.get(2);

		// note1 = title3
		then(note1.getUser().getUsername()).isEqualTo("username2");
		then(note1.getTitle()).isEqualTo("title3"); // 최신 노트가 먼저 조회되어야 함
		then(note1.getContent()).isEqualTo("content3");
		// note2 = title2
		then(note2.getUser().getUsername()).isEqualTo("username");
		then(note2.getTitle()).isEqualTo("title2");
		then(note2.getContent()).isEqualTo("content2");
		// note3 = title1
		then(note3.getUser().getUsername()).isEqualTo("username");
		then(note3.getTitle()).isEqualTo("title1");
		then(note3.getContent()).isEqualTo("content1");
	}

	// 노트를 저장하는 경우를 테스트합니다.
	@Test
	void saveNote() {
		// given
		User user = userRepository.save(new User("username", "password", "ROLE_USER"));
		// when
		noteService.saveNote(user, "title1", "content1");
		// then
		then(noteRepository.count()).isOne(); // 노트가 저장되었는지 확인
	}

	// 노트를 삭제하는 경우를 테스트합니다.
	@Test
	void deleteNote() {
		User user = userRepository.save(new User("username", "password", "ROLE_USER"));
		Note note = noteRepository.save(new Note("title1", "content1", user));
		noteService.deleteNote(user, note.getId());
		// then
		then(noteRepository.count()).isZero(); // 노트가 삭제되었는지 확인
	}
}

 

// NoticeControllerTest

@SpringBootTest // 스프링 부트 애플리케이션 컨텍스트를 로드하여 테스트 환경을 설정합니다.
@Transactional // 각 테스트 메소드가 실행된 후 롤백되어 테스트 간 데이터 오염을 방지합니다.
class NoticeControllerTest {

	@Autowired
	private NoticeRepository noticeRepository; // NoticeRepository를 주입합니다.
	private MockMvc mockMvc; // MockMvc 객체를 선언합니다.

	@BeforeEach
	public void setUp(@Autowired WebApplicationContext applicationContext) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
			.apply(springSecurity()) // Spring Security를 적용합니다.
			.alwaysDo(print()) // 모든 요청과 응답을 콘솔에 출력합니다.
			.build();
	}

	// 인증 없이 공지사항을 조회하는 경우를 테스트합니다.
	@Test
	void getNotice_인증없음() throws Exception {
		mockMvc.perform(get("/notice"))
			.andExpect(redirectedUrlPattern("**/login")) // 로그인 페이지로 리다이렉트되는지 확인합니다.
			.andExpect(status().is3xxRedirection()); // HTTP 상태 코드가 리다이렉션인지 확인합니다.
	}

	// 인증된 사용자가 공지사항을 조회하는 경우를 테스트합니다.
	@Test
	@WithMockUser // 인증된 가짜 사용자를 사용하여 테스트합니다.
	void getNotice_인증있음() throws Exception {
		mockMvc.perform(get("/notice"))
			.andExpect(status().isOk()) // HTTP 상태 코드가 200인지 확인합니다.
			.andExpect(view().name("notice/index")); // 뷰 이름이 "notice/index"인지 확인합니다.
	}

	// 인증 없이 공지사항을 게시하는 경우를 테스트합니다.
	@Test
	void postNotice_인증없음() throws Exception {
		mockMvc.perform(
			post("/notice")
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.param("title", "제목")
				.param("content", "내용")
		).andExpect(status().isForbidden()); // 접근 거부
	}

	// 유저 권한으로 공지사항을 게시하는 경우를 테스트합니다.
	@Test
	@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
	void postNotice_유저인증있음() throws Exception {
		mockMvc.perform(
			post("/notice").with(csrf()) // CSRF 토큰을 함께 전송합니다.
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.param("title", "제목")
				.param("content", "내용")
		).andExpect(status().isForbidden()); // 접근 거부
	}

	// 어드민 권한으로 공지사항을 게시하는 경우를 테스트합니다.
	@Test
	@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
	void postNotice_어드민인증있음() throws Exception {
		mockMvc.perform(
				post("/notice").with(csrf()) // CSRF 토큰을 함께 전송합니다.
					.contentType(MediaType.APPLICATION_FORM_URLENCODED)
					.param("title", "제목")
					.param("content", "내용")
			).andExpect(redirectedUrl("notice")) // 공지사항 페이지로 리다이렉트되는지 확인합니다.
			.andExpect(status().is3xxRedirection()); // HTTP 상태 코드가 리다이렉션인지 확인합니다.
	}

	// 인증 없이 공지사항을 삭제하는 경우를 테스트합니다.
	@Test
	void deleteNotice_인증없음() throws Exception {
		Notice notice = noticeRepository.save(new Notice("제목", "내용"));
		mockMvc.perform(
			delete("/notice?id=" + notice.getId())
		).andExpect(status().isForbidden()); // 접근 거부
	}

	// 유저 권한으로 공지사항을 삭제하는 경우를 테스트합니다.
	@Test
	@WithMockUser(roles = {"USER"}, username = "admin", password = "admin")
	void deleteNotice_유저인증있음() throws Exception {
		Notice notice = noticeRepository.save(new Notice("제목", "내용"));
		mockMvc.perform(
			delete("/notice?id=" + notice.getId()).with(csrf()) // CSRF 토큰을 함께 전송합니다.
		).andExpect(status().isForbidden()); // 접근 거부
	}

	// 어드민 권한으로 공지사항을 삭제하는 경우를 테스트합니다.
	@Test
	@WithMockUser(roles = {"ADMIN"}, username = "admin", password = "admin")
	void deleteNotice_어드민인증있음() throws Exception {
		Notice notice = noticeRepository.save(new Notice("제목", "내용"));
		mockMvc.perform(
				delete("/notice?id=" + notice.getId()).with(csrf()) // CSRF 토큰을 함께 전송합니다.
			).andExpect(redirectedUrl("notice")) // 공지사항 페이지로 리다이렉트되는지 확인합니다.
			.andExpect(status().is3xxRedirection()); // HTTP 상태 코드가 리다이렉션인지 확인합니다.
	}
}

 

// SignUpControllerTest

@SpringBootTest // 스프링 부트 애플리케이션 컨텍스트를 로드하여 테스트 환경을 설정합니다.
@Transactional // 각 테스트 메소드가 실행된 후 롤백되어 테스트 간 데이터 오염을 방지합니다.
class SignUpControllerTest {

	private MockMvc mockMvc; // MockMvc 객체를 선언합니다.

	@BeforeEach
	public void setUp(@Autowired WebApplicationContext applicationContext) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
			.apply(springSecurity()) // Spring Security를 적용합니다.
			.alwaysDo(print()) // 모든 요청과 응답을 콘솔에 출력합니다.
			.build();
	}

	// 회원가입을 테스트합니다.
	@Test
	void signup() throws Exception {
		mockMvc.perform(
				post("/signup").with(csrf()) // CSRF 토큰을 함께 전송합니다.
					.contentType(MediaType.APPLICATION_FORM_URLENCODED)
					.param("username", "user123") // 회원 정보를 파라미터로 전송합니다.
					.param("password", "password")
			).andExpect(redirectedUrl("login")) // 로그인 페이지로 리다이렉트되는지 확인합니다.
			.andExpect(status().is3xxRedirection()); // HTTP 상태 코드가 리다이렉션인지 확인합니다.
	}
}

 

// UserServiceTest

@SpringBootTest // 스프링 부트 애플리케이션 컨텍스트를 로드하여 테스트 환경을 설정합니다.
@ActiveProfiles(profiles = "test") // 활성화된 프로파일이 "test"인 경우를 지정합니다.
@Transactional // 각 테스트 메소드가 실행된 후 롤백되어 테스트 간 데이터 오염을 방지합니다.
class UserServiceTest {

	@Autowired
	private UserService userService; // UserService를 주입합니다.
	@Autowired
	private UserRepository userRepository; // UserRepository를 주입합니다.

	// 회원 가입을 테스트합니다.
	@Test
	void signup() {
		// given
		String username = "user123";
		String password = "password";

		// when
		User user = userService.signup(username, password);

		// then
		then(user.getId()).isNotNull(); // id가 NotNull인지 검증
		then(user.getUsername()).isEqualTo("user123"); // 유저명이 user123인지 검증
		then(user.getPassword()).startsWith("{bcrypt}"); // 패스워드가 {bcrypt}로 시작하는지 검증
		then(user.getAuthorities()).hasSize(1); // Authorities가 1개인지 검증
		then(user.getAuthorities().stream().findFirst().get().getAuthority()).isEqualTo("ROLE_USER");
		then(user.isAdmin()).isFalse(); // 어드민 여부가 False인지 검증
		then(user.isAccountNonExpired()).isTrue();
		then(user.isAccountNonLocked()).isTrue();
		then(user.isEnabled()).isTrue();
		then(user.isCredentialsNonExpired()).isTrue();
	}

	// 어드민 회원 가입을 테스트합니다.
	@Test
	void signupAdmin() {
		// given
		String username = "admin123";
		String password = "password";

		// when
		User user = userService.signupAdmin(username, password);

		// then
		then(user.getId()).isNotNull();
		then(user.getUsername()).isEqualTo("admin123");
		then(user.getPassword()).startsWith("{bcrypt}");
		then(user.getAuthorities()).hasSize(1);
		then(user.getAuthorities().stream().findFirst().get().getAuthority()).isEqualTo("ROLE_ADMIN");
		then(user.isAdmin()).isTrue();
		then(user.isAccountNonExpired()).isTrue();
		then(user.isAccountNonLocked()).isTrue();
		then(user.isEnabled()).isTrue();
		then(user.isCredentialsNonExpired()).isTrue();
	}

	// 유저명으로 유저를 조회하는 테스트를 수행합니다.
	@Test
	void findByUsername() {
		// given
		userRepository.save(new User("user123", "password", "ROLE_USER"));

		// when
		User user = userService.findByUsername("user123");

		// then
		then(user.getId()).isNotNull();
	}
}

 

결과

'개발 > Spring' 카테고리의 다른 글

Spring Security Config  (1) 2024.03.01
Spring security Architecture, Filter  (0) 2024.02.29
Spring Security 구현  (0) 2024.02.27
Spring Security  (0) 2024.02.27
Querydsl  (1) 2024.02.27