View Javadoc
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 }