package de.stadtbote.votian; import com.fasterxml.jackson.databind.ObjectMapper; import de.stadtbote.votian.database.DatabaseService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.mock.web.MockHttpSession; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"}) @AutoConfigureMockMvc class ApiSecurityTest { @Autowired MockMvc mvc; @Autowired ObjectMapper json; @MockitoBean DatabaseService database; record Csrf(MockHttpSession session, String token, String header) {} @BeforeEach void configure() { when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden")); when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password"))) .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111"))); } Csrf csrf(MockHttpSession session) throws Exception { var request = get("/api/csrf"); if (session != null) request.session(session); MvcResult response = mvc.perform(request).andExpect(status().isOk()) .andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store"))) .andReturn(); var body = json.readTree(response.getResponse().getContentAsString()); return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText()); } MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception { // Represents upstream htaccess authentication: VoTiAn must ignore this header. return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token()) .with(httpBasic("proxy-fixture","proxy-password")) .contentType("application/x-www-form-urlencoded").param("username",username).param("password",password)) .andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn(); } MockHttpSession authenticated(String username, String password) throws Exception { return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false); } @Test void healthAndSystemArePublicButDataRequiresSession() throws Exception { mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password"))) .andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP")); mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true)) .andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false)); MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized()) .andExpect(header().doesNotExist("WWW-Authenticate")).andReturn(); assertThat(unauthorized.getRequest().getSession(false)).isNull(); Csrf csrf = csrf(null); mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token()) .contentType("application/json").content("{}")) .andExpect(status().isUnauthorized()); } @Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception { Csrf anonymous = csrf(null); String previousId = anonymous.session().getId(); MvcResult login = login(anonymous,"existing-user","existing-password",200); var body = json.readTree(login.getResponse().getContentAsString()); assertThat(body.get("username").asText()).isEqualTo("existing-user"); assertThat(body.has("password")).isFalse(); MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); assertThat(session.getId()).isNotEqualTo(previousId); SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); assertThat(context.getAuthentication().getCredentials()).isNull(); mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()) .andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER")) .andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true)) .andExpect(jsonPath("$.capabilities.writeOrders").value(false)); mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password"))) .andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user")); verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any()); } @Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception { mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password"))) .andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate")); verify(database,never()).queryForList(anyString(),any(),any(),any()); } @Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception { mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password")) .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password")) .andExpect(status().isUnauthorized()); verify(database,never()).queryForList(anyString(),any(),any(),any()); } @Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception { Csrf anonymous = csrf(null); MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false); mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token())) .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()); Csrf authenticated = csrf(session); mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token())) .andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0)); assertThat(session.isInvalid()).isTrue(); mvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); Csrf afterLogout = csrf(null); mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token()) .param("username","existing-user").param("password","existing-password")) .andExpect(status().isForbidden()); login(afterLogout,"existing-user","existing-password",200); } @Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception { MockHttpSession session = authenticated("existing-user","existing-password"); mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden()); mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden()); mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()); } @Test void csrfCannotBeReusedAcrossSessions() throws Exception { Csrf first = csrf(null); Csrf second = csrf(null); mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token()) .param("username","existing-user").param("password","existing-password")) .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID")); verify(database,never()).queryForList(anyString(),any(),any(),any()); } @Test void noWriteEndpointCanModifyTheDatabase() throws Exception { MockHttpSession session = authenticated("existing-user","existing-password"); Csrf csrf = csrf(session); mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token()) .contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}")) .andExpect(status().isNotImplemented()); verify(database,never()).update(anyString(),any(Object[].class)); } @Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception { when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password"))) .thenThrow(new DataAccessResourceFailureException("server secret must not be exposed")); var result = login(csrf(null),"offline","password",503); assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE); assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull(); when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE)); mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false)); } @Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception { Csrf wrong = csrf(null); login(wrong,"unknown","incorrect",401); mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized()); when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password"))) .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111"))); login(csrf(null),"invalid","password",401); } @Test void rightsAreEnforcedBeyondAuthentication() throws Exception { when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password"))) .thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000"))); MockHttpSession session = authenticated("restricted","password"); mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false)); mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden()); mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden()); mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden()); } @Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception { when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen.")); mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false)) .andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED")) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe"))); } }