Unit tests: - JwtTokenProviderTest (7): round-trip, tampered sig, expired, wrong secret - UserServiceTest (6): Mifflin-St Jeor BMR for lose/maintain/gain, manual override - MealServiceTest (6): calorie calc, food-memory create/avg, IDOR ownership Integration tests: - ValidationIntegrationTest (12): 400s for invalid email, short password, barcode pattern, food query length, history range >90 days - ProfileAndMealIntegrationTest (11): profile CRUD, meal get/delete lifecycle, history, cross-user IDOR (403 on GET and DELETE), data-leakage check Fixes bundled: - GlobalExceptionHandler: add ConstraintViolationException (400) and MissingServletRequestParameterException (400) handlers - AiService: remove unused spring-ai import (compile fix)
312 lines
13 KiB
Java
312 lines
13 KiB
Java
// Generated by GitHub Copilot
|
|
package com.caloriecounter;
|
|
|
|
import com.caloriecounter.dto.auth.RegisterRequest;
|
|
import com.caloriecounter.dto.user.UserProfileDto;
|
|
import com.caloriecounter.entity.FoodItem;
|
|
import com.caloriecounter.entity.UserProfile;
|
|
import com.caloriecounter.repository.FoodItemRepository;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
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.http.MediaType;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import org.springframework.test.web.servlet.MvcResult;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.LocalDate;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.UUID;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
|
|
/**
|
|
* Integration tests covering:
|
|
* 1. User profile CRUD (REQ-PRF-001, REQ-PRF-002)
|
|
* 2. Meal get / delete lifecycle (REQ-MEAL-003)
|
|
* 3. Meal history (REQ-HIST-001)
|
|
* 4. Cross-user IDOR protection — user A must not access user B's meals (REQ-SEC-003)
|
|
*/
|
|
@SpringBootTest
|
|
@AutoConfigureMockMvc
|
|
@Transactional
|
|
class ProfileAndMealIntegrationTest {
|
|
|
|
@Autowired MockMvc mvc;
|
|
@Autowired ObjectMapper objectMapper;
|
|
@Autowired FoodItemRepository foodItemRepository;
|
|
|
|
// ─── Profile ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /user/profile before any profile is saved must return 200 with all-null fields.
|
|
*/
|
|
@Test
|
|
void getProfile_noneSet_returns200WithNullFields() throws Exception {
|
|
String token = registerAndGetToken("profile-empty@example.com");
|
|
|
|
mvc.perform(get("/user/profile")
|
|
.header("Authorization", "Bearer " + token))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.age").doesNotExist())
|
|
.andExpect(jsonPath("$.weightKg").doesNotExist())
|
|
.andExpect(jsonPath("$.dailyCaloriesTarget").doesNotExist());
|
|
}
|
|
|
|
/**
|
|
* PUT /user/profile with full biometrics must return a non-null calculated
|
|
* dailyCaloriesTarget and store the correct goal.
|
|
*/
|
|
@Test
|
|
void updateProfile_fullBiometrics_returnCalculatedTarget() throws Exception {
|
|
String token = registerAndGetToken("profile-full@example.com");
|
|
|
|
UserProfileDto dto = new UserProfileDto(
|
|
30,
|
|
new BigDecimal("80"),
|
|
new BigDecimal("175"),
|
|
UserProfile.Goal.maintain,
|
|
null);
|
|
|
|
mvc.perform(put("/user/profile")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(dto)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.goal").value("maintain"))
|
|
.andExpect(jsonPath("$.dailyCaloriesTarget").isNumber());
|
|
}
|
|
|
|
/**
|
|
* PUT followed by GET must persist the profile.
|
|
*/
|
|
@Test
|
|
void updateThenGetProfile_persistsChanges() throws Exception {
|
|
String token = registerAndGetToken("profile-persist@example.com");
|
|
|
|
UserProfileDto dto = new UserProfileDto(
|
|
25,
|
|
new BigDecimal("70"),
|
|
new BigDecimal("170"),
|
|
UserProfile.Goal.lose,
|
|
null);
|
|
|
|
mvc.perform(put("/user/profile")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(dto)))
|
|
.andExpect(status().isOk());
|
|
|
|
mvc.perform(get("/user/profile")
|
|
.header("Authorization", "Bearer " + token))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.age").value(25))
|
|
.andExpect(jsonPath("$.goal").value("lose"))
|
|
.andExpect(jsonPath("$.dailyCaloriesTarget").isNumber());
|
|
}
|
|
|
|
/**
|
|
* Manual calorie override (null biometrics) must be persisted as-is.
|
|
*/
|
|
@Test
|
|
void updateProfile_manualCalorieOverride_persistsValue() throws Exception {
|
|
String token = registerAndGetToken("profile-manual@example.com");
|
|
|
|
UserProfileDto dto = new UserProfileDto(null, null, null, null, 1800);
|
|
|
|
mvc.perform(put("/user/profile")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(dto)))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.dailyCaloriesTarget").value(1800));
|
|
}
|
|
|
|
// ─── Meal lifecycle ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /meals/{id} must return the meal that was just created with correct totals.
|
|
*/
|
|
@Test
|
|
void createThenGetMeal_returnsCorrectMeal() throws Exception {
|
|
String token = registerAndGetToken("get-meal@example.com");
|
|
FoodItem food = seedFood("Get Meal Food", new BigDecimal("100"));
|
|
|
|
UUID mealId = createMealAndGetId(token, food.getId(), new BigDecimal("150"));
|
|
|
|
mvc.perform(get("/meals/" + mealId)
|
|
.header("Authorization", "Bearer " + token))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.id").value(mealId.toString()))
|
|
.andExpect(jsonPath("$.items[0].calories").value(150.00));
|
|
}
|
|
|
|
/**
|
|
* DELETE /meals/{id} must return 204, and a subsequent GET must return 404.
|
|
*/
|
|
@Test
|
|
void deleteMeal_returns204_thenGetReturns404() throws Exception {
|
|
String token = registerAndGetToken("delete-meal@example.com");
|
|
FoodItem food = seedFood("Delete Meal Food", new BigDecimal("200"));
|
|
|
|
UUID mealId = createMealAndGetId(token, food.getId(), new BigDecimal("100"));
|
|
|
|
mvc.perform(delete("/meals/" + mealId)
|
|
.header("Authorization", "Bearer " + token))
|
|
.andExpect(status().isNoContent());
|
|
|
|
mvc.perform(get("/meals/" + mealId)
|
|
.header("Authorization", "Bearer " + token))
|
|
.andExpect(status().isNotFound());
|
|
}
|
|
|
|
/**
|
|
* GET /meals/history with a valid 30-day range must return a list (possibly empty).
|
|
*/
|
|
@Test
|
|
void mealHistory_validRange_returns200() throws Exception {
|
|
String token = registerAndGetToken("history-valid@example.com");
|
|
LocalDate to = LocalDate.now();
|
|
LocalDate from = to.minusDays(30);
|
|
|
|
mvc.perform(get("/meals/history")
|
|
.header("Authorization", "Bearer " + token)
|
|
.param("from", from.toString())
|
|
.param("to", to.toString()))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$").isArray());
|
|
}
|
|
|
|
/**
|
|
* Created meals appear in the history response for the correct date range.
|
|
*/
|
|
@Test
|
|
void mealHistory_afterCreatingMeal_mealAppearsInHistory() throws Exception {
|
|
String token = registerAndGetToken("history-populated@example.com");
|
|
FoodItem food = seedFood("History Food", new BigDecimal("100"));
|
|
|
|
createMealAndGetId(token, food.getId(), new BigDecimal("100"));
|
|
|
|
LocalDate today = LocalDate.now();
|
|
mvc.perform(get("/meals/history")
|
|
.header("Authorization", "Bearer " + token)
|
|
.param("from", today.toString())
|
|
.param("to", today.toString()))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$[0].mealType").value("lunch"));
|
|
}
|
|
|
|
// ─── IDOR: cross-user data isolation ────────────────────────────────────
|
|
|
|
/**
|
|
* User B must receive 403 when attempting to GET User A's meal.
|
|
* Verifies the ownership check in MealService.findAndCheckOwnership() at the HTTP layer.
|
|
*/
|
|
@Test
|
|
void getMeal_otherUsersToken_returns403() throws Exception {
|
|
String tokenA = registerAndGetToken("idor-a@example.com");
|
|
String tokenB = registerAndGetToken("idor-b@example.com");
|
|
FoodItem food = seedFood("IDOR Food GET", new BigDecimal("100"));
|
|
|
|
UUID mealIdOfA = createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
|
|
|
mvc.perform(get("/meals/" + mealIdOfA)
|
|
.header("Authorization", "Bearer " + tokenB))
|
|
.andExpect(status().isForbidden())
|
|
.andExpect(jsonPath("$.detail").value("Access denied"));
|
|
}
|
|
|
|
/**
|
|
* User B must receive 403 when attempting to DELETE User A's meal.
|
|
*/
|
|
@Test
|
|
void deleteMeal_otherUsersToken_returns403() throws Exception {
|
|
String tokenA = registerAndGetToken("idor-del-a@example.com");
|
|
String tokenB = registerAndGetToken("idor-del-b@example.com");
|
|
FoodItem food = seedFood("IDOR Food DEL", new BigDecimal("100"));
|
|
|
|
UUID mealIdOfA = createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
|
|
|
mvc.perform(delete("/meals/" + mealIdOfA)
|
|
.header("Authorization", "Bearer " + tokenB))
|
|
.andExpect(status().isForbidden())
|
|
.andExpect(jsonPath("$.detail").value("Access denied"));
|
|
|
|
// Verify the meal was NOT deleted — owner can still access it
|
|
mvc.perform(get("/meals/" + mealIdOfA)
|
|
.header("Authorization", "Bearer " + tokenA))
|
|
.andExpect(status().isOk());
|
|
}
|
|
|
|
/**
|
|
* User B's daily overview must not include meals from User A on the same date.
|
|
*/
|
|
@Test
|
|
void dailyOverview_doesNotLeakOtherUsersMeals() throws Exception {
|
|
String tokenA = registerAndGetToken("leak-a@example.com");
|
|
String tokenB = registerAndGetToken("leak-b@example.com");
|
|
FoodItem food = seedFood("Leak Food", new BigDecimal("500"));
|
|
|
|
// User A logs a large meal (500 kcal)
|
|
createMealAndGetId(tokenA, food.getId(), new BigDecimal("100"));
|
|
|
|
// User B's overview for the same day must show 0 calories
|
|
mvc.perform(get("/meals/daily")
|
|
.header("Authorization", "Bearer " + tokenB)
|
|
.param("date", LocalDate.now().toString()))
|
|
.andExpect(status().isOk())
|
|
.andExpect(jsonPath("$.totalCalories").value(0));
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
|
|
private String registerAndGetToken(String email) throws Exception {
|
|
MvcResult result = mvc.perform(post("/auth/register")
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(objectMapper.writeValueAsString(
|
|
new RegisterRequest(email, "password123"))))
|
|
.andExpect(status().isCreated())
|
|
.andReturn();
|
|
Map<?, ?> body = objectMapper.readValue(
|
|
result.getResponse().getContentAsString(), Map.class);
|
|
return (String) body.get("token");
|
|
}
|
|
|
|
private FoodItem seedFood(String name, BigDecimal caloriesPer100g) {
|
|
return foodItemRepository.save(FoodItem.builder()
|
|
.name(name)
|
|
.source(FoodItem.Source.custom)
|
|
.caloriesPer100g(caloriesPer100g)
|
|
.proteinG(BigDecimal.ZERO)
|
|
.fatG(BigDecimal.ZERO)
|
|
.carbsG(BigDecimal.ZERO)
|
|
.build());
|
|
}
|
|
|
|
private UUID createMealAndGetId(String token, UUID foodId, BigDecimal grams) throws Exception {
|
|
String payload = objectMapper.writeValueAsString(Map.of(
|
|
"date", LocalDate.now().toString(),
|
|
"mealType", "lunch",
|
|
"source", "manual",
|
|
"items", List.of(Map.of("foodItemId", foodId, "grams", grams))
|
|
));
|
|
|
|
MvcResult result = mvc.perform(post("/meals")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.content(payload))
|
|
.andExpect(status().isCreated())
|
|
.andReturn();
|
|
|
|
Map<?, ?> body = objectMapper.readValue(
|
|
result.getResponse().getContentAsString(), Map.class);
|
|
return UUID.fromString((String) body.get("id"));
|
|
}
|
|
}
|