diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 20d5916..1aeb9d0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,47 +1,106 @@ -name: CI +name: CI/CD + on: push: - branches: [main, dev] + branches: + - dev + - main + pull_request: - branches: [main] + branches: + - main + +env: + JAVA_VERSION: "21" jobs: - test: + backend-unit-tests: + name: Backend Unit 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 + - 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 - working-directory: backend + run: chmod +x gradlew - - name: Run tests - run: ./gradlew test --no-daemon + - name: Run unit tests + run: ./gradlew test + + 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 - env: - SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/etfo-db - SPRING_DATASOURCE_USERNAME: test - SPRING_DATASOURCE_PASSWORD: test + 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 7a7e9c4..f3e878f 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 @@ -32,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/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/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/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..640afe4 --- /dev/null +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/service/JWTServiceTest.java @@ -0,0 +1,62 @@ +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.test.context.TestPropertySource; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +class JWTServiceTest { + + private JWTService jwtService; + + 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(); + } + + @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); + } + + 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 78% rename from backend/src/test/resources/application.properties rename to backend/src/test/resources/application-test.properties index d3c2bf6..d67c387 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application-test.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= 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