1 package fr.tiogars.chat.domains.lmstudio; 2 3 import static org.assertj.core.api.Assertions.assertThat; 4 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 5 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 6 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 7 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 8 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 9 10 import java.util.Set; 11 12 import com.jayway.jsonpath.JsonPath; 13 import org.junit.jupiter.api.AfterEach; 14 import org.junit.jupiter.api.BeforeEach; 15 import org.junit.jupiter.api.Tag; 16 import org.junit.jupiter.api.Test; 17 import org.junit.jupiter.params.ParameterizedTest; 18 import org.junit.jupiter.params.provider.ValueSource; 19 import org.springframework.beans.factory.annotation.Autowired; 20 import org.springframework.boot.test.context.SpringBootTest; 21 import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 22 import org.springframework.http.MediaType; 23 import org.springframework.test.context.DynamicPropertyRegistry; 24 import org.springframework.test.context.DynamicPropertySource; 25 import org.springframework.test.web.servlet.MockMvc; 26 import org.testcontainers.junit.jupiter.Container; 27 import org.testcontainers.junit.jupiter.Testcontainers; 28 import org.testcontainers.postgresql.PostgreSQLContainer; 29 30 import fr.tiogars.chat.domains.auth.user.models.CreateUserRequest; 31 import fr.tiogars.chat.domains.auth.user.services.UserService; 32 import fr.tiogars.chat.domains.lmstudio.models.LmStudioSettings; 33 import fr.tiogars.chat.domains.lmstudio.repositories.LmStudioSettingsRepository; 34 import fr.tiogars.chat.domains.lmstudio.services.LmStudioSettingsService; 35 36 @SpringBootTest 37 @AutoConfigureMockMvc 38 @Testcontainers 39 @Tag("Layer1") 40 class LmStudioSettingsL1Test { 41 42 private static final String PATH = "/api/v1/lmstudio/settings"; 43 private static final String SETTINGS = """ 44 {"baseUrl":"http://localhost:1234/","model":"test-model","timeoutSeconds":30} 45 """; 46 47 @Container 48 private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine"); 49 50 @DynamicPropertySource 51 static void databaseProperties(DynamicPropertyRegistry registry) { 52 registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); 53 registry.add("spring.datasource.username", POSTGRES::getUsername); 54 registry.add("spring.datasource.password", POSTGRES::getPassword); 55 registry.add("spring.jpa.hibernate.ddl-auto", () -> "update"); 56 } 57 58 @Autowired 59 private MockMvc mvc; 60 61 @Autowired 62 private LmStudioSettingsRepository repository; 63 64 @Autowired 65 private UserService userService; 66 67 private String adminToken; 68 69 @BeforeEach 70 void setUp() throws Exception { 71 repository.deleteAll(); 72 adminToken = login("admin", "admin"); 73 } 74 75 @AfterEach 76 void cleanUp() { 77 repository.deleteAll(); 78 userService.findByUsername("lmstudio-reader").ifPresent(user -> userService.delete(user.id())); 79 } 80 81 @Test 82 void settings_should_roundtripThroughApiAndDatabase_and_replaceSingleRow() throws Exception { 83 mvc.perform(get(PATH).header("Authorization", adminToken)).andExpect(status().isNotFound()); 84 mvc.perform(put(PATH).header("Authorization", adminToken) 85 .contentType(MediaType.APPLICATION_JSON).content(SETTINGS)) 86 .andExpect(status().isOk()) 87 .andExpect(jsonPath("$.baseUrl").value("http://localhost:1234/")) 88 .andExpect(jsonPath("$.model").value("test-model")) 89 .andExpect(jsonPath("$.timeoutSeconds").value(30)) 90 .andExpect(jsonPath("$.baseUrlValid").doesNotExist()); 91 92 LmStudioSettingsService freshService = new LmStudioSettingsService(repository); 93 assertThat(freshService.requireSettings()) 94 .isEqualTo(new LmStudioSettings("http://localhost:1234/", "test-model", 30)); 95 96 mvc.perform(put(PATH).header("Authorization", adminToken) 97 .contentType(MediaType.APPLICATION_JSON) 98 .content(SETTINGS.replace("test-model", "replacement").replace("30", "60"))) 99 .andExpect(status().isOk()); 100 mvc.perform(get(PATH).header("Authorization", adminToken)) 101 .andExpect(status().isOk()) 102 .andExpect(jsonPath("$.model").value("replacement")) 103 .andExpect(jsonPath("$.timeoutSeconds").value(60)); 104 assertThat(freshService.requireSettings().model()).isEqualTo("replacement"); 105 assertThat(repository.count()).isEqualTo(1); 106 } 107 108 @Test 109 void token_should_bePersistedPreservedReplacedAndRemoved_withoutBeingReturned() throws Exception { 110 String tokenSettings = SETTINGS.replace("\"timeoutSeconds\":30", "\"timeoutSeconds\":30,\"token\":\"test-token\""); 111 mvc.perform(put(PATH).header("Authorization", adminToken) 112 .contentType(MediaType.APPLICATION_JSON).content(tokenSettings)) 113 .andExpect(status().isOk()) 114 .andExpect(jsonPath("$.token").doesNotExist()) 115 .andExpect(jsonPath("$.tokenConfigured").value(true)); 116 mvc.perform(get(PATH).header("Authorization", adminToken)) 117 .andExpect(status().isOk()) 118 .andExpect(jsonPath("$.token").doesNotExist()) 119 .andExpect(jsonPath("$.tokenConfigured").value(true)); 120 assertThat(repository.findById(1L).orElseThrow().toSettings().token()).isEqualTo("test-token"); 121 assertThat(repository.findById(1L).orElseThrow().toSettings().toString()).doesNotContain("test-token"); 122 123 for (String body : new String[] { SETTINGS, tokenSettings.replace("\"test-token\"", "null") }) { 124 mvc.perform(put(PATH).header("Authorization", adminToken) 125 .contentType(MediaType.APPLICATION_JSON).content(body)) 126 .andExpect(status().isOk()).andExpect(jsonPath("$.tokenConfigured").value(true)); 127 assertThat(repository.findById(1L).orElseThrow().toSettings().token()).isEqualTo("test-token"); 128 } 129 mvc.perform(put(PATH).header("Authorization", adminToken) 130 .contentType(MediaType.APPLICATION_JSON).content(tokenSettings.replace("test-token", "replacement-token"))) 131 .andExpect(status().isOk()).andExpect(jsonPath("$.token").doesNotExist()); 132 assertThat(repository.findById(1L).orElseThrow().toSettings().token()).isEqualTo("replacement-token"); 133 134 mvc.perform(put(PATH).header("Authorization", adminToken) 135 .contentType(MediaType.APPLICATION_JSON).content(tokenSettings.replace("test-token", ""))) 136 .andExpect(status().isOk()) 137 .andExpect(jsonPath("$.tokenConfigured").value(false)); 138 assertThat(repository.findById(1L).orElseThrow().toSettings().token()).isNull(); 139 } 140 141 @ParameterizedTest 142 @ValueSource(strings = {"Bearer token", "token\\r\\nInjected:value", " ", "token\\tinvalid"}) 143 void token_should_rejectInvalidBearerValues(String token) throws Exception { 144 String body = SETTINGS.replace("\"timeoutSeconds\":30", "\"timeoutSeconds\":30,\"token\":\"" + token + "\""); 145 mvc.perform(put(PATH).header("Authorization", adminToken) 146 .contentType(MediaType.APPLICATION_JSON).content(body)) 147 .andExpect(status().isBadRequest()); 148 assertThat(repository.count()).isZero(); 149 } 150 151 @Test 152 void token_should_acceptLmStudioColonFormat_and_persistItUnchanged() throws Exception { 153 String token = "sk-lm-testIdentifier:testSecret"; 154 String body = SETTINGS.replace("\"timeoutSeconds\":30", "\"timeoutSeconds\":30,\"token\":\"" + token + "\""); 155 mvc.perform(put(PATH).header("Authorization", adminToken) 156 .contentType(MediaType.APPLICATION_JSON).content(body)) 157 .andExpect(status().isOk()) 158 .andExpect(jsonPath("$.token").doesNotExist()) 159 .andExpect(jsonPath("$.tokenConfigured").value(true)); 160 assertThat(repository.findById(1L).orElseThrow().toSettings().token()).isEqualTo(token); 161 } 162 163 @Test 164 void application_should_startWithoutSettings_and_reportUnavailableOnPrompt() throws Exception { 165 mvc.perform(get("/actuator/health")).andExpect(status().isOk()); 166 mvc.perform(post("/api/prompts").header("Authorization", adminToken) 167 .contentType(MediaType.APPLICATION_JSON).content("{\"prompt\":\"hello\"}")) 168 .andExpect(status().isServiceUnavailable()); 169 assertThat(repository.count()).isZero(); 170 } 171 172 @Test 173 void settings_should_denyUnauthenticatedAndNonAdminAccess() throws Exception { 174 mvc.perform(get(PATH)).andExpect(status().isForbidden()); 175 mvc.perform(put(PATH).contentType(MediaType.APPLICATION_JSON).content(SETTINGS)) 176 .andExpect(status().isForbidden()); 177 mvc.perform(get(PATH).header("Authorization", "Bearer invalid")).andExpect(status().isForbidden()); 178 179 userService.create(new CreateUserRequest("lmstudio-reader", "reader-password", true, Set.of("USER"))); 180 String userToken = login("lmstudio-reader", "reader-password"); 181 mvc.perform(get(PATH).header("Authorization", userToken)).andExpect(status().isForbidden()); 182 mvc.perform(put(PATH).header("Authorization", userToken) 183 .contentType(MediaType.APPLICATION_JSON).content(SETTINGS)) 184 .andExpect(status().isForbidden()); 185 assertThat(repository.count()).isZero(); 186 } 187 188 @ParameterizedTest 189 @ValueSource(strings = { 190 "{}", 191 "{\"baseUrl\":\"http://localhost:1234\",\"model\":\"model\",\"timeoutSeconds\":0}", 192 "{\"baseUrl\":\"http://localhost:1234\",\"model\":\"model\",\"timeoutSeconds\":-1}", 193 "{\"baseUrl\":\"http://localhost:1234\",\"model\":\" \",\"timeoutSeconds\":30}", 194 "{\"baseUrl\":\"localhost:1234\",\"model\":\"model\",\"timeoutSeconds\":30}", 195 "{\"baseUrl\":\"ftp://localhost:1234\",\"model\":\"model\",\"timeoutSeconds\":30}", 196 "{\"baseUrl\":\"http://host:70000\",\"model\":\"model\",\"timeoutSeconds\":30}", 197 "{\"baseUrl\":\"http://user:password@host\",\"model\":\"model\",\"timeoutSeconds\":30}", 198 "{\"baseUrl\":\"http://host?query=1\",\"model\":\"model\",\"timeoutSeconds\":30}", 199 "{\"baseUrl\":\"http://host#fragment\",\"model\":\"model\",\"timeoutSeconds\":30}" 200 }) 201 void settings_should_rejectInvalidValues_withoutOverwritingPersistedConfiguration(String invalid) throws Exception { 202 mvc.perform(put(PATH).header("Authorization", adminToken) 203 .contentType(MediaType.APPLICATION_JSON).content(SETTINGS)).andExpect(status().isOk()); 204 mvc.perform(put(PATH).header("Authorization", adminToken) 205 .contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest()); 206 mvc.perform(get(PATH).header("Authorization", adminToken)) 207 .andExpect(status().isOk()).andExpect(jsonPath("$.model").value("test-model")); 208 } 209 210 private String login(String username, String password) throws Exception { 211 String response = mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON) 212 .content("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}")) 213 .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); 214 return "Bearer " + JsonPath.<String>read(response, "$.token"); 215 } 216 }