From 09b6193077985b94aba33c0b84f73e15e7d89fd4 Mon Sep 17 00:00:00 2001 From: Ksan Date: Wed, 10 Jun 2026 14:36:38 +0200 Subject: [PATCH 1/4] added some tests and fixed server secret to be persistent --- .gitea/workflows/ci.yaml | 39 +--- .gitignore | 3 + backend/build.gradle | 1 + backend/compose.yaml | 8 +- .../controller/EntryController.java | 18 +- .../controller/UserController.java | 6 +- .../etfoglasiserver/service/JWTService.java | 15 +- .../src/main/resources/application.properties | 5 - .../resources/application.properties.example | 8 + .../controller/EntryControllerTest.java | 156 +++++++++++++ .../controller/UserControllerTest.java | 216 ++++++++++++++++++ .../service/JWTServiceTest.java | 56 +++++ .../src/test/resources/application.properties | 3 +- 13 files changed, 483 insertions(+), 51 deletions(-) delete mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/main/resources/application.properties.example create mode 100644 backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java create mode 100644 backend/src/test/java/dev/ksan/etfoglasiserver/controller/UserControllerTest.java create mode 100644 backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 20d5916..0b4c00a 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,47 +1,30 @@ name: CI + on: push: - branches: [main, dev] + branches: [dev] pull_request: branches: [main] jobs: - test: + backend-tests: runs-on: ubuntu-latest - - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_DB: etfo-db - POSTGRES_USER: test - POSTGRES_PASSWORD: test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - + defaults: + run: + working-directory: backend steps: - - name: Checkout - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Java 21 - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' + cache: gradle - name: Make Gradle wrapper executable run: chmod +x ./gradlew - working-directory: backend - name: Run tests run: ./gradlew test --no-daemon - working-directory: backend - env: - SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/etfo-db - SPRING_DATASOURCE_USERNAME: test - SPRING_DATASOURCE_PASSWORD: test + + # TODO: CD logic — deploy after successful tests on merge to main diff --git a/.gitignore b/.gitignore index 7a7e9c4..85b4e10 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # GLOBAL (all projects) ######################## +todo + # IDEs .idea/ .vscode/ @@ -22,6 +24,7 @@ backend/.gradle/ backend/build/ backend/bin/ +backend/src/main/resources/application.properties backend/.env backend/.creds diff --git a/backend/build.gradle b/backend/build.gradle index 4d8acd3..dd304c9 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -45,6 +45,7 @@ dependencies { + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation("org.testcontainers:postgresql:1.21.3") diff --git a/backend/compose.yaml b/backend/compose.yaml index 3ffe9ef..d8d187a 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -3,11 +3,11 @@ services: image: 'postgres:16' container_name: etfo-db environment: - - 'POSTGRES_DB=etfo-db' - - 'POSTGRES_PASSWORD=test' - - 'POSTGRES_USER=test' + - POSTGRES_DB=etfo-db + - POSTGRES_PASSWORD=test + - POSTGRES_USER=test volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql - ./.db:/var/lib/postgresql/data ports: - - '5432:5432' + - 5432:5432 diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java index 898c568..f18cebc 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java @@ -11,7 +11,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api") @@ -36,16 +38,30 @@ public class EntryController { } + + @GetMapping("/entries") public Page getEntries( @RequestParam(required = false) String subjectId, @RequestParam(required = false) String groupName, @RequestParam(defaultValue = "0") int page ) { - UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null; +// UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null; + + UUID parsedSubjectId = null; + if (subjectId != null) { + try{ + parsedSubjectId = UUID.fromString(subjectId); + }catch (IllegalArgumentException e){ + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid subject ID"); + } + } + + return service.getEntries(parsedSubjectId, groupName, page); } + @PutMapping("/entries") public void updateEntry(@RequestBody EntryDTO entry) { service.updateEntry(entry); diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/UserController.java b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/UserController.java index 0e8c379..df68627 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/UserController.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/UserController.java @@ -15,6 +15,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api") @@ -26,8 +27,11 @@ public class UserController { @GetMapping("/users/me") public ResponseEntity getUserById(Authentication authentication) { String email = authentication.getName(); + User user = service.getAccountByEmail(email) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "User not found" + )); return ResponseEntity.ok(new UserDTO(user)); } diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java index 64d95ba..34a6898 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java @@ -12,24 +12,17 @@ import javax.crypto.SecretKey; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; @Service public class JWTService { - //TODO add persistent token env variable - private String secretKey = ""; + @Value("${jwt.secret}") + private String secretKey; + - private JWTService() { - try { - KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256"); - SecretKey secKey = keyGen.generateKey(); - secretKey = Base64.getEncoder().encodeToString(secKey.getEncoded()); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - } public String generateToken(String email) { Map claims = new HashMap<>(); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties deleted file mode 100644 index ca491f4..0000000 --- a/backend/src/main/resources/application.properties +++ /dev/null @@ -1,5 +0,0 @@ -spring.application.name=etfoglasi-server -spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db -spring.datasource.username=test -spring.datasource.password=test - diff --git a/backend/src/main/resources/application.properties.example b/backend/src/main/resources/application.properties.example new file mode 100644 index 0000000..0e4785e --- /dev/null +++ b/backend/src/main/resources/application.properties.example @@ -0,0 +1,8 @@ +spring.application.name= + +spring.datasource.url= +spring.datasource.username= +spring.datasource.password= + +# maybe use > openssl rand -base64 32 +jwt.secret= diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java new file mode 100644 index 0000000..5ecac6a --- /dev/null +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java @@ -0,0 +1,156 @@ +package dev.ksan.etfoglasiserver.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.ksan.etfoglasiserver.config.SecurityConfig; +import dev.ksan.etfoglasiserver.model.Entry; +import dev.ksan.etfoglasiserver.service.EntryService; +import dev.ksan.etfoglasiserver.service.JWTService; +import dev.ksan.etfoglasiserver.service.MyUserDetailsService; +import dev.ksan.etfoglasiserver.util.SubjectLoader; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + + +import java.util.List; +import java.util.UUID; + +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(EntryController.class) +@Import(SecurityConfig.class) +class EntryControllerTest { + + @Autowired + MockMvc mvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + EntryService entryService; + + @MockitoBean + JWTService jwtService; + + + @MockitoBean + MyUserDetailsService userDetailsService; + + @MockitoBean + SubjectLoader subjectLoader; + + + @Test + void getEntries_noParams_returns200() throws Exception { + when(entryService.getEntries(null, null, 0)) + .thenReturn(new PageImpl<>(List.of())); + + mvc.perform(get("/api/entries")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + void getEntries_withSubjectId_parsesUUIDCorrectly() throws Exception { + UUID subjectId = UUID.randomUUID(); + when(entryService.getEntries(eq(subjectId), isNull(), eq(0))) + .thenReturn(new PageImpl<>(List.of())); + + mvc.perform(get("/api/entries") + .param("subjectId", subjectId.toString()) + .param("page", "0")) + .andExpect(status().isOk()); + + verify(entryService).getEntries(eq(subjectId), isNull(), eq(0)); + } + + @Test + void getEntries_withGroupName_passesGroupNameToService() throws Exception { + when(entryService.getEntries(isNull(), eq("Physics"), eq(0))) + .thenReturn(new PageImpl<>(List.of())); + + mvc.perform(get("/api/entries").param("groupName", "Physics")) + .andExpect(status().isOk()); + + verify(entryService).getEntries(isNull(), eq("Physics"), eq(0)); + } + + @Test + void getEntries_invalidUUID_returns400() throws Exception { + mvc.perform(get("/api/entries").param("subjectId", "not-a-uuid")) + .andExpect(status().isBadRequest()); + } + + + @Test + void getGroups_returnsList() throws Exception { + when(entryService.getAllGroups()).thenReturn(List.of("Math", "Physics", "CS")); + + mvc.perform(get("/api/groups")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(3)) + .andExpect(jsonPath("$[0]").value("Math")); + } + + @Test + void getGroups_empty_returns200WithEmptyArray() throws Exception { + when(entryService.getAllGroups()).thenReturn(List.of()); + + mvc.perform(get("/api/groups")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + + @Test + @WithMockUser + void getEntry_existingId_returns200() throws Exception { + Entry entry = new Entry(); + entry.setTitle("Test Entry"); + + when(entryService.getEntryById("entry-1")).thenReturn(entry); + + mvc.perform(get("/api/entries/entry-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Test Entry")); + } + + @Test + void getEntry_unauthenticated_returns401or403() throws Exception { + mvc.perform(get("/api/entries/entry-1")) + .andExpect(status().is(anyOf(equalTo(401), equalTo(403)))); + } + + + @Test + @WithMockUser + void deleteEntry_authenticated_returns200() throws Exception { + doNothing().when(entryService).deleteEntry("entry-1"); + + mvc.perform(delete("/api/entries/entry-1")) + .andExpect(status().isOk()); + } + + @Test + void deleteEntry_unauthenticated_returns401or403() throws Exception { + mvc.perform(delete("/api/entries/entry-1")) + .andExpect(status().is(anyOf(equalTo(401), equalTo(403)))); + } +} \ No newline at end of file diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/UserControllerTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/UserControllerTest.java new file mode 100644 index 0000000..f9774e3 --- /dev/null +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/UserControllerTest.java @@ -0,0 +1,216 @@ +package dev.ksan.etfoglasiserver.controller; + +import dev.ksan.etfoglasiserver.config.SecurityConfig; +import dev.ksan.etfoglasiserver.dto.JwtResponseDTO; +import dev.ksan.etfoglasiserver.dto.UserCreationDTO; +import dev.ksan.etfoglasiserver.dto.UserDTO; +import dev.ksan.etfoglasiserver.dto.UserLoginDTO; +import dev.ksan.etfoglasiserver.model.User; +import dev.ksan.etfoglasiserver.service.JWTService; +import dev.ksan.etfoglasiserver.service.MyUserDetailsService; +import dev.ksan.etfoglasiserver.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.ksan.etfoglasiserver.util.SubjectLoader; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; +import java.util.UUID; + +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserController.class) +@Import(SecurityConfig.class) +class UserControllerTest { + + @Autowired + MockMvc mvc; + + @Autowired + ObjectMapper objectMapper; + + @MockitoBean + SubjectLoader subjectLoader; + + @MockitoBean + UserService userService; + + @MockitoBean + JWTService jwtService; + + @MockitoBean + MyUserDetailsService userDetailsService; + + private static final String EMAIL = "test@example.com"; + + @Test + void login_validCredentials_returns200WithToken() throws Exception { + JwtResponseDTO response = new JwtResponseDTO("jwt-token", EMAIL, "user-id", "OK"); + when(userService.verify(any(UserLoginDTO.class))).thenReturn(response); + + mvc.perform(post("/api/login") + .contentType(APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"correct"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value("jwt-token")) + .andExpect(jsonPath("$.email").value(EMAIL)); + } + + @Test + void login_badCredentials_returns400() throws Exception { + when(userService.verify(any())) + .thenThrow(new RuntimeException("bad credentials")); + + mvc.perform(post("/api/login") + .contentType(APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"wrong"} + """)) + .andExpect(status().isBadRequest()); + } + + @Test + void login_nullResponse_returns400() throws Exception { + when(userService.verify(any())).thenReturn(null); + + mvc.perform(post("/api/login") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UserLoginDTO("x@x.com", "p")))) + .andExpect(status().isBadRequest()); + } + + @Test + void register_newUser_returns201() throws Exception { + User user = new User(); + user.setEmail(EMAIL); + + when(userService.register(any())) + .thenReturn(new UserDTO(user)); + + mvc.perform(post("/api/register") + .contentType(APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"pass123"} + """)) + .andExpect(status().isCreated()); + } + + @Test + void register_duplicateEmail_returns400() throws Exception { + when(userService.register(any())) + .thenThrow(new RuntimeException("Email already exists")); + + mvc.perform(post("/api/register") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UserCreationDTO("dupe@example.com", "pass")))) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = EMAIL) + void getUser_authenticated_returns200() throws Exception { + User user = new User(); + user.setEmail(EMAIL); + + when(userService.getAccountByEmail(EMAIL)) + .thenReturn(Optional.of(user)); + + mvc.perform(get("/api/users/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value(EMAIL)); + } + + @Test + void getUser_unauthenticated_returns401or403() throws Exception { + mvc.perform(get("/api/users/me")) + .andExpect(status().is(anyOf(equalTo(401), equalTo(403)))); + } + + @Test + @WithMockUser(username = EMAIL) + void getUser_notFound_returns404() throws Exception { + when(userService.getAccountByEmail(EMAIL)) + .thenReturn(Optional.empty()); + + mvc.perform(get("/api/users/me")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = EMAIL) + void updateUser_validBody_returns200() throws Exception { + User user = new User(); + user.setEmail("new@example.com"); + + when(userService.updateUser(eq(EMAIL), any())) + .thenReturn(new UserDTO(user)); + + mvc.perform(put("/api/users/me") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new UserCreationDTO("new@example.com", "newpass")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("new@example.com")); + } + + @Test + @WithMockUser(username = EMAIL) + void addSubject_validId_returns200() throws Exception { + UUID subjectId = UUID.randomUUID(); + + User user = new User(); + user.setEmail(EMAIL); + + when(userService.addSubject(EMAIL, subjectId)) + .thenReturn(new UserDTO(user)); + + mvc.perform(put("/api/users/me/subjects/{id}", subjectId)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = EMAIL) + void removeSubject_validId_returns200() throws Exception { + UUID subjectId = UUID.randomUUID(); + + User user = new User(); + user.setEmail(EMAIL); + + when(userService.removeSubject(EMAIL, subjectId)) + .thenReturn(new UserDTO(user)); + + mvc.perform(delete("/api/users/me/subjects/{id}", subjectId)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = EMAIL) + void deleteUser_authenticated_returns204() throws Exception { + doNothing().when(userService).deleteUser(EMAIL); + + mvc.perform(delete("/api/users/me")) + .andExpect(status().isNoContent()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java new file mode 100644 index 0000000..cb2b536 --- /dev/null +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java @@ -0,0 +1,56 @@ +package dev.ksan.etfoglasiserver.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@SpringBootTest +@TestPropertySource(locations = "classpath:application.properties") +class JWTServiceTest { + + @Autowired + JWTService jwtService; + + private org.springframework.security.core.userdetails.UserDetails userDetails(String email) { + return org.springframework.security.core.userdetails.User + .withUsername(email) + .password("irrelevant") + .roles("USER") + .build(); + } + + @Test + void generateToken_extractEmail_returnsCorrectEmail() { + String token = jwtService.generateToken("alice@example.com"); + + assertThat(jwtService.extractEmail(token)).isEqualTo("alice@example.com"); + } + + @Test + void validateToken_correctUser_returnsTrue() { + String token = jwtService.generateToken("alice@example.com"); + + assertThat(jwtService.validateToken(token, userDetails("alice@example.com"))).isTrue(); + } + + @Test + void validateToken_differentUser_returnsFalse() { + String token = jwtService.generateToken("alice@example.com"); + + assertThat(jwtService.validateToken(token, userDetails("bob@example.com"))).isFalse(); + } + + @Test + void validateToken_tamperedToken_throwsException() { + String token = jwtService.generateToken("alice@example.com"); + String tampered = token.substring(0, token.length() - 4) + "XXXX"; + + assertThatThrownBy(() -> jwtService.validateToken(tampered, userDetails("alice@example.com"))) + .isInstanceOf(Exception.class); + } +} \ No newline at end of file diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index d3c2bf6..d67c387 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -4,4 +4,5 @@ spring.datasource.username=test spring.datasource.password=test spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -firebase.enabled=false \ No newline at end of file +firebase.enabled=false +jwt.secret=testkeynekikaojer123451231231231231231231231= From d598fdca1a389280660a8e23fa426fd7e75ab578 Mon Sep 17 00:00:00 2001 From: Ksan Date: Wed, 10 Jun 2026 14:44:15 +0200 Subject: [PATCH 2/4] removed cache --- .gitea/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0b4c00a..423bf7d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -19,7 +19,6 @@ jobs: with: java-version: '21' distribution: 'temurin' - cache: gradle - name: Make Gradle wrapper executable run: chmod +x ./gradlew From 28bd6a8cbc994f43d828e79dd0ae1ab247d579fb Mon Sep 17 00:00:00 2001 From: Ksan Date: Wed, 10 Jun 2026 14:56:19 +0200 Subject: [PATCH 3/4] fixed jwtservicetest not to enclude entire spring boot test --- .../service/JWTServiceTest.java | 36 +++++++++++-------- ...properties => application-test.properties} | 0 2 files changed, 21 insertions(+), 15 deletions(-) rename backend/src/test/resources/{application.properties => application-test.properties} (100%) diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java index cb2b536..640afe4 100644 --- a/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java @@ -1,47 +1,49 @@ package dev.ksan.etfoglasiserver.service; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.test.context.TestPropertySource; +import java.lang.reflect.Field; + import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; -@SpringBootTest -@TestPropertySource(locations = "classpath:application.properties") +@ExtendWith(MockitoExtension.class) class JWTServiceTest { - @Autowired - JWTService jwtService; + private JWTService jwtService; - private org.springframework.security.core.userdetails.UserDetails userDetails(String email) { - return org.springframework.security.core.userdetails.User - .withUsername(email) - .password("irrelevant") - .roles("USER") - .build(); + private static final String TEST_SECRET = + "dGVzdHNlY3JldGtleXRoYXRpc2xvbmdlbm91Z2hmb3JibWFjc2hhMjU2dGVzdGtleQ=="; + + @BeforeEach + void setUp() throws Exception { + jwtService = new JWTService(); + Field secret = JWTService.class.getDeclaredField("secretKey"); + secret.setAccessible(true); + secret.set(jwtService, TEST_SECRET); } @Test void generateToken_extractEmail_returnsCorrectEmail() { String token = jwtService.generateToken("alice@example.com"); - assertThat(jwtService.extractEmail(token)).isEqualTo("alice@example.com"); } @Test void validateToken_correctUser_returnsTrue() { String token = jwtService.generateToken("alice@example.com"); - assertThat(jwtService.validateToken(token, userDetails("alice@example.com"))).isTrue(); } @Test void validateToken_differentUser_returnsFalse() { String token = jwtService.generateToken("alice@example.com"); - assertThat(jwtService.validateToken(token, userDetails("bob@example.com"))).isFalse(); } @@ -49,8 +51,12 @@ class JWTServiceTest { void validateToken_tamperedToken_throwsException() { String token = jwtService.generateToken("alice@example.com"); String tampered = token.substring(0, token.length() - 4) + "XXXX"; - assertThatThrownBy(() -> jwtService.validateToken(tampered, userDetails("alice@example.com"))) .isInstanceOf(Exception.class); } + + private org.springframework.security.core.userdetails.UserDetails userDetails(String email) { + return org.springframework.security.core.userdetails.User + .withUsername(email).password("x").roles("USER").build(); + } } \ No newline at end of file diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application-test.properties similarity index 100% rename from backend/src/test/resources/application.properties rename to backend/src/test/resources/application-test.properties From b53805ee3fb5e022c586dccdfac432df47661225 Mon Sep 17 00:00:00 2001 From: Ksan Date: Wed, 10 Jun 2026 18:03:56 +0200 Subject: [PATCH 4/4] testing new ci/cd --- .gitea/workflows/ci.yaml | 103 +++++++++++++++--- .gitignore | 1 + backend/Dockerfile | 5 + .../config/FirebaseConfig.java | 2 +- ci-full.yaml | 24 ++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 ci-full.yaml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 423bf7d..1aeb9d0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,29 +1,106 @@ -name: CI +name: CI/CD on: push: - branches: [dev] + branches: + - dev + - main + pull_request: - branches: [main] + branches: + - main + +env: + JAVA_VERSION: "21" jobs: - backend-tests: + backend-unit-tests: + name: Backend Unit Tests runs-on: ubuntu-latest + defaults: run: working-directory: backend - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 with: - java-version: '21' - distribution: 'temurin' + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} - name: Make Gradle wrapper executable - run: chmod +x ./gradlew + run: chmod +x gradlew - - name: Run tests - run: ./gradlew test --no-daemon + - name: Run unit tests + run: ./gradlew test - # TODO: CD logic — deploy after successful tests on merge to main + deploy: + name: Deploy + needs: backend-unit-tests + + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Build boot jar + working-directory: backend + run: | + chmod +x gradlew + ./gradlew bootJar + + - name: Stage jar for Docker + working-directory: backend + run: | + BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1) + cp "$BOOT_JAR" app.jar + + - name: Login to Docker registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login git.${{ secrets.DOMAIN }} \ + -u "${{ secrets.REGISTRY_USER }}" \ + --password-stdin + + - name: Build Docker image + run: | + docker build \ + -t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \ + backend/ + + - name: Push Docker image + run: | + docker push \ + git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest + + - name: Deploy to VPS + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/key + chmod 600 ~/.ssh/key + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + # Write secrets to VPS (only if they've changed or first deploy) + ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "mkdir -p ~/programs/etf-oglasi-server/config" + + + echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \ + ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "cat > ~/programs/etf-oglasi-server/config/firebase.json" + + ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d" diff --git a/.gitignore b/.gitignore index 85b4e10..f3e878f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ backend/**/*temp.java backend/src/main/resources/google-services.json backend/src/main/resources/etf-oglasi-firebase.json +backend/src/main/resources/firebase.json # Java / Eclipse / STS .classpath diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..dcc36fa --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre-jammy +WORKDIR /app +COPY app.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/FirebaseConfig.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/FirebaseConfig.java index f2efcef..cdc5b1c 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/FirebaseConfig.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/FirebaseConfig.java @@ -17,7 +17,7 @@ public class FirebaseConfig { @Bean public FirebaseApp firebaseApp() throws IOException { GoogleCredentials credentials = GoogleCredentials - .fromStream(new ClassPathResource("etf-oglasi-firebase.json").getInputStream()) + .fromStream(new ClassPathResource("firebase.json").getInputStream()) .createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging")); FirebaseOptions options = FirebaseOptions.builder() diff --git a/ci-full.yaml b/ci-full.yaml new file mode 100644 index 0000000..8c644a7 --- /dev/null +++ b/ci-full.yaml @@ -0,0 +1,24 @@ +name: CI - full + +on: + pull_request: + branches: [main] + +jobs: + backend-test: + uses: ./.gitea/workflows/backend-test.yaml + + frontend-build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: npm ci + - name: Build frontend + run: npx expo export --platform web