diff --git a/backend/Dockerfile b/backend/Dockerfile index 0b4c221..e62a95d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,6 +7,7 @@ COPY src src RUN ./mvnw package -DskipTests -B FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache libwebp WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 diff --git a/backend/src/main/java/com/recipeapp/auth/AuthController.java b/backend/src/main/java/com/recipeapp/auth/AuthController.java index d0f6605..93abcb1 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthController.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthController.java @@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; -import java.util.List; @RestController @RequestMapping("/v1/auth") @@ -32,7 +27,7 @@ public class AuthController { @Valid @RequestBody SignupRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.signup(request); - authenticateInSession(user.email(), "user", httpRequest); + authService.authenticateInSession(user.email(), "user", httpRequest); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); } @@ -41,30 +36,10 @@ public class AuthController { @Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.login(request); - // Session fixation protection: invalidate old session before creating new one - var oldSession = httpRequest.getSession(false); - if (oldSession != null) { - oldSession.invalidate(); - } - authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); + authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(user)); } - /** - * Creates an authenticated Spring Security context and stores it in the HTTP session - * so that subsequent requests from the same session are recognised as authenticated. - * We do this manually because we are not using Spring Security's built-in form login. - */ - private void authenticateInSession(String email, String role, HttpServletRequest request) { - var auth = UsernamePasswordAuthenticationToken.authenticated( - email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(auth); - SecurityContextHolder.setContext(context); - request.getSession(true).setAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - } - @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest httpRequest) { HttpSession session = httpRequest.getSession(false); diff --git a/backend/src/main/java/com/recipeapp/auth/AuthService.java b/backend/src/main/java/com/recipeapp/auth/AuthService.java index 1f007c6..feaee08 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthService.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthService.java @@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.HouseholdMemberRepository; import com.recipeapp.household.entity.HouseholdMember; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service public class AuthService { @@ -82,6 +90,24 @@ public class AuthService { return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName()); } + /** + * Establishes an authenticated Spring Security session for the given user. + * Invalidates any existing session first (session fixation protection). + */ + public void authenticateInSession(String email, String role, HttpServletRequest request) { + var oldSession = request.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } + private UserResponse toUserResponse(UserAccount user) { return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail()) .map(member -> UserResponse.withHousehold( diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index 27c35dc..7de26fe 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -24,11 +24,13 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/v1/invites/**").permitAll() .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .sessionManagement(session -> session + .sessionFixation().changeSessionId() .maximumSessions(1)); return http.build(); diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 46c8556..c3eb59d 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -1,7 +1,9 @@ package com.recipeapp.household; +import com.recipeapp.auth.AuthService; import com.recipeapp.common.ApiResponse; import com.recipeapp.household.dto.*; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -17,9 +19,11 @@ import java.util.UUID; public class HouseholdController { private final HouseholdService householdService; + private final AuthService authService; - public HouseholdController(HouseholdService householdService) { + public HouseholdController(HouseholdService householdService, AuthService authService) { this.householdService = householdService; + this.authService = authService; } @PostMapping("/households") @@ -71,11 +75,20 @@ public class HouseholdController { return ResponseEntity.ok(ApiResponse.success(response)); } + @GetMapping("/invites/{code}") + public ResponseEntity> getInviteInfo(@PathVariable String code) { + InviteInfoResponse response = householdService.getInviteInfo(code); + return ResponseEntity.ok(ApiResponse.success(response)); + } + @PostMapping("/invites/{code}/accept") public ResponseEntity> acceptInvite( - Principal principal, - @PathVariable String code) { - AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code); + @PathVariable String code, + @Valid @RequestBody AcceptInviteRequest request, + HttpServletRequest httpRequest) { + AcceptInviteResponse response = householdService.acceptInvite( + code, request.name(), request.email(), request.password()); + authService.authenticateInSession(request.email(), "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(response)); } } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index bcf207c..e72a9d4 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException; import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.dto.*; +import org.springframework.security.crypto.password.PasswordEncoder; import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; @@ -38,6 +39,7 @@ public class HouseholdService { private final IngredientCategoryRepository ingredientCategoryRepository; private final TagRepository tagRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository; + private final PasswordEncoder passwordEncoder; @Value("${app.base-url}") private String baseUrl; @@ -52,7 +54,8 @@ public class HouseholdService { IngredientRepository ingredientRepository, IngredientCategoryRepository ingredientCategoryRepository, TagRepository tagRepository, - VarietyScoreConfigRepository varietyScoreConfigRepository) { + VarietyScoreConfigRepository varietyScoreConfigRepository, + PasswordEncoder passwordEncoder) { this.userAccountRepository = userAccountRepository; this.householdRepository = householdRepository; this.householdMemberRepository = householdMemberRepository; @@ -61,6 +64,7 @@ public class HouseholdService { this.ingredientCategoryRepository = ingredientCategoryRepository; this.tagRepository = tagRepository; this.varietyScoreConfigRepository = varietyScoreConfigRepository; + this.passwordEncoder = passwordEncoder; } @Transactional @@ -161,37 +165,57 @@ public class HouseholdService { householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) .ifPresent(existing -> { existing.setInvalidatedAt(Instant.now()); - householdInviteRepository.save(existing); + householdInviteRepository.saveAndFlush(existing); }); String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); - HouseholdInvite invite = householdInviteRepository.save( - new HouseholdInvite(household, code, expiresAt)); + HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt); + invite.setInvitedBy(member.getUser()); + householdInviteRepository.save(invite); return toInviteResponse(invite); } - @Transactional - public AcceptInviteResponse acceptInvite(String userEmail, String code) { - UserAccount user = findUser(userEmail); + @Transactional(readOnly = true) + public InviteInfoResponse getInviteInfo(String code) { + HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) { - throw new ConflictException("User is already in a household"); + if ("used".equals(invite.getStatus()) + || invite.getInvalidatedAt() != null + || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); + } + + String inviterName = invite.getInvitedBy() != null + ? invite.getInvitedBy().getDisplayName() + : invite.getHousehold().getCreatedBy().getDisplayName(); + + return new InviteInfoResponse(invite.getHousehold().getName(), inviterName); + } + + @Transactional + public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) { + if (userAccountRepository.existsByEmailIgnoreCase(email)) { + throw new ConflictException("Email already registered"); } HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) - .orElseThrow(() -> new ResourceNotFoundException("Invite not found")); + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if ("used".equals(invite.getStatus())) { - throw new ConflictException("Invite code already used"); - } - if (invite.getExpiresAt().isBefore(Instant.now())) { - throw new ValidationException("Invite code has expired"); + if ("used".equals(invite.getStatus()) + || invite.getInvalidatedAt() != null + || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); } + UserAccount user = userAccountRepository.save( + new UserAccount(email, name, passwordEncoder.encode(rawPassword))); + invite.setStatus("used"); + invite.setInvalidatedAt(Instant.now()); householdInviteRepository.save(invite); Household household = invite.getHousehold(); diff --git a/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java new file mode 100644 index 0000000..cc132f4 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java @@ -0,0 +1,11 @@ +package com.recipeapp.household.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AcceptInviteRequest( + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java new file mode 100644 index 0000000..97d31c6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.household.dto; + +public record InviteInfoResponse( + String householdName, + String inviterName +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 04246e6..56e7a9f 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -1,5 +1,6 @@ package com.recipeapp.household.entity; +import com.recipeapp.auth.entity.UserAccount; import jakarta.persistence.*; import java.time.Instant; import java.util.UUID; @@ -16,6 +17,10 @@ public class HouseholdInvite { @JoinColumn(name = "household_id", nullable = false) private Household household; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invited_by") + private UserAccount invitedBy; + @Column(name = "invite_code", nullable = false, unique = true, length = 20) private String inviteCode; @@ -38,6 +43,8 @@ public class HouseholdInvite { public UUID getId() { return id; } public Household getHousehold() { return household; } + public UserAccount getInvitedBy() { return invitedBy; } + public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; } public String getInviteCode() { return inviteCode; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql index c9a5511..f210870 100644 --- a/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql +++ b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql @@ -1,6 +1,17 @@ ALTER TABLE household_invite ADD COLUMN invalidated_at timestamptz; +-- Mark all but the most-recent invite per household as invalidated, +-- so the unique partial index below can be created on dev databases +-- that accumulated multiple pending invites before this migration was added. +UPDATE household_invite +SET invalidated_at = NOW() +WHERE id NOT IN ( + SELECT DISTINCT ON (household_id) id + FROM household_invite + ORDER BY household_id, expires_at DESC +); + CREATE UNIQUE INDEX uq_household_invite_active ON household_invite (household_id) WHERE invalidated_at IS NULL; diff --git a/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql new file mode 100644 index 0000000..f1519c8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql @@ -0,0 +1,2 @@ +ALTER TABLE household_invite + ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL; diff --git a/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java index f82a582..882bafc 100644 --- a/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java +++ b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java @@ -10,19 +10,17 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.UUID; -import static org.hamcrest.Matchers.notNullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) @@ -100,7 +98,7 @@ class AuthControllerTest { } @Test - void signupShouldStoreSecurityContextInSession() throws Exception { + void signupShouldDelegateSessionCreationToAuthService() throws Exception { var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah"); var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah"); @@ -109,14 +107,13 @@ class AuthControllerTest { mockMvc.perform(post("/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(request().sessionAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, - notNullValue())); + .andExpect(status().isCreated()); + + verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any()); } @Test - void loginShouldStoreSecurityContextInSession() throws Exception { + void loginShouldDelegateSessionCreationToAuthService() throws Exception { var request = new LoginRequest("sarah@example.com", "s3cure!Pass"); var response = UserResponse.withHousehold( UUID.randomUUID(), "sarah@example.com", "Sarah", @@ -127,10 +124,9 @@ class AuthControllerTest { mockMvc.perform(post("/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(request().sessionAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, - notNullValue())); + .andExpect(status().isOk()); + + verify(authService).authenticateInSession(eq("sarah@example.com"), eq("user"), any()); } @Test diff --git a/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java new file mode 100644 index 0000000..7bfe67b --- /dev/null +++ b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java @@ -0,0 +1,51 @@ +package com.recipeapp.auth; + +import com.recipeapp.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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.result.MockMvcResultMatchers.status; + +class SecurityConfigTest extends AbstractIntegrationTest { + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception { + // 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401 + mockMvc.perform(get("/v1/invites/ANYCODE")) + .andExpect(status().isNotFound()); + } + + @Test + void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception { + // 400 = validation error (empty body), but NOT 401 — proves the path is permitted + mockMvc.perform(post("/v1/invites/ANYCODE/accept") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + void protectedEndpointRequiresAuthentication() throws Exception { + mockMvc.perform(get("/v1/households/mine")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index 1a33db3..b7bb1c8 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -1,7 +1,10 @@ package com.recipeapp.household; import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.auth.AuthService; import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ConflictException; import com.recipeapp.household.dto.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,6 +37,9 @@ class HouseholdControllerTest { @Mock private HouseholdService householdService; + @Mock + private AuthService authService; + @InjectMocks private HouseholdController householdController; @@ -158,16 +164,67 @@ class HouseholdControllerTest { } @Test - void acceptInviteShouldReturn200() throws Exception { - var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception { + var response = new InviteInfoResponse("Smith family", "Sarah"); - when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response); + when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response); + + mockMvc.perform(get("/v1/invites/ABC12XYZ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.householdName").value("Smith family")) + .andExpect(jsonPath("$.data.inviterName").value("Sarah")); + } + + @Test + void getInviteInfoShouldReturn404WhenInvalid() throws Exception { + when(householdService.getInviteInfo("BADTOKEN")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(get("/v1/invites/BADTOKEN")) + .andExpect(status().isNotFound()); + } + + @Test + void acceptInviteShouldReturn200AndCreateSession() throws Exception { + var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenReturn(response); mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") - .principal(() -> "tom@example.com")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.data.householdName").value("Smith family")) .andExpect(jsonPath("$.data.role").value("member")); } + + @Test + void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ConflictException("Email already registered")); + + mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(post("/v1/invites/BADTOKEN/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 6545566..223d5cd 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -41,6 +41,7 @@ class HouseholdServiceTest { @Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private TagRepository tagRepository; @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; + @Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; @InjectMocks private HouseholdService householdService; @@ -154,86 +155,150 @@ class HouseholdServiceTest { assertThat(result.shareUrl()).endsWith(result.inviteCode()); } + // ── getInviteInfo ───────────────────────────────────────────────────────── + @Test - void acceptInviteShouldAddUserAsMember() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void getInviteInfoShouldReturnHouseholdNameAndInviterName() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); - when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); - AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ"); + InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ"); assertThat(result.householdName()).isEqualTo("Smith family"); - assertThat(result.role()).isEqualTo("member"); + assertThat(result.inviterName()).isEqualTo("Sarah"); } @Test - void acceptInviteShouldThrowWhenAlreadyInHousehold() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); - var household = new Household("Other", user); - var member = new HouseholdMember(household, user, "member"); + void getInviteInfoShouldThrow404WhenCodeNotFound() { + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.getInviteInfo("INVALID")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeExpired() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); + invite.setStatus("used"); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("USED123")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenInviteIsInvalidated() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); + invite.setInvalidatedAt(Instant.now()); // superseded by a new invite + + when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED")) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── acceptInvite (new: creates account + joins) ─────────────────────────── + + @Test + void acceptInviteShouldCreateAccountAndAddAsMember() { + var owner = testUser(); + var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member)); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0)); + when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); + when(passwordEncoder.encode("secret123")).thenReturn("hashed"); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ")) + AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"); + + assertThat(result.householdName()).isEqualTo("Smith family"); + assertThat(result.role()).isEqualTo("member"); + verify(userAccountRepository).save(any(UserAccount.class)); + verify(householdMemberRepository).save(any(HouseholdMember.class)); + } + + @Test + void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true); + + assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ConflictException.class); } @Test - void acceptInviteShouldThrowWhenCodeExpired() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeExpired() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED")) - .isInstanceOf(ValidationException.class); + assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123")) + .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenCodeAlreadyUsed() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeAlreadyUsed() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); invite.setStatus("used"); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123")) - .isInstanceOf(ConflictException.class); - } - - @Test - void acceptInviteShouldThrowWhenInviteNotFound() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); - - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); - when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID")) + assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenUserNotFound() { - when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + void acceptInviteShouldThrow404WhenInviteNotFound() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ")) + assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void acceptInviteShouldThrow404WhenInviteIsInvalidated() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400)); + invite.setInvalidatedAt(Instant.now()); // superseded by a new invite + + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); } @@ -470,11 +535,13 @@ class HouseholdServiceTest { when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite)); + when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); householdService.createInvite("sarah@example.com"); assertThat(existingInvite.getInvalidatedAt()).isNotNull(); - verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class)); + verify(householdInviteRepository).saveAndFlush(existingInvite); + verify(householdInviteRepository).save(any(HouseholdInvite.class)); } } diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 4a59d78..4da0791 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); - it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])( + it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])( 'allows public route %s without auth', async (path) => { const { event, resolve } = createEvent(path); @@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => { } ); + it('redirects authenticated user on /join/[token] to /', async () => { + const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/'); + } + }); + it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( 'allows static asset %s without auth', async (path) => { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6f2ea43..705b340 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { apiClient } from '$lib/server/api'; -const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite']; +const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join']; const STATIC_PREFIXES = ['/_app/', '/favicon']; @@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never { export const handle: Handle = async ({ event, resolve }) => { if (isPublicRoute(event.url.pathname)) { + const isJoinRoute = event.url.pathname.startsWith('/join/'); + if (isJoinRoute && event.cookies.get('JSESSIONID')) { + throw redirect(302, '/'); + } return resolve(event); } diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index a6cc337..d80b106 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -148,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/invites/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInviteInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/invites/{code}/accept": { parameters: { query?: never; @@ -739,6 +755,20 @@ export interface components { data?: components["schemas"]["AcceptInviteResponse"]; meta?: components["schemas"]["Meta"]; }; + InviteInfoResponse: { + householdName?: string; + inviterName?: string; + }; + ApiResponseInviteInfoResponse: { + status?: string; + data?: components["schemas"]["InviteInfoResponse"]; + meta?: components["schemas"]["Meta"]; + }; + AcceptInviteRequest: { + name: string; + email: string; + password: string; + }; Meta: { pagination?: components["schemas"]["Pagination"]; }; @@ -1345,7 +1375,7 @@ export interface operations { }; }; }; - acceptInvite: { + getInviteInfo: { parameters: { query?: never; header?: never; @@ -1355,6 +1385,37 @@ export interface operations { cookie?: never; }; requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInviteInfoResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + }; + }; + acceptInvite: { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInviteRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1365,6 +1426,16 @@ export interface operations { "*/*": components["schemas"]["ApiResponseAcceptInviteResponse"]; }; }; + /** @description Email already registered */ + 409: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + /** @description Invite not found or invalid */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; }; }; listCategories: { diff --git a/frontend/src/routes/(public)/join/[token]/+page.server.ts b/frontend/src/routes/(public)/join/[token]/+page.server.ts new file mode 100644 index 0000000..deea5ae --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.server.ts @@ -0,0 +1,84 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import { apiClient } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/invites/{code}', { + params: { path: { code: params.token } } + }); + + if (error || !data?.data) { + return { invalid: true }; + } + + return { + invalid: false, + householdName: data.data.householdName ?? '', + inviterName: data.data.inviterName ?? '' + }; +}; + +export const actions = { + default: async ({ params, request, fetch, cookies }) => { + const formData = await request.formData(); + const name = (formData.get('name') ?? '').toString().trim(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + if (!name) { + errors.name = 'Name ist erforderlich'; + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (password.length < 8) { + errors.password = 'Mindestens 8 Zeichen'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, name, email }); + } + + const api = apiClient(fetch); + const { error, response } = await api.POST('/v1/invites/{code}/accept', { + params: { path: { code: params.token } }, + body: { name, email, password } + }); + + if (error) { + if (error.status === 409) { + return fail(409, { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + }, + name, + email + }); + } + return fail(400, { + errors: { form: 'Einladung ungültig oder abgelaufen.' }, + name, + email + }); + } + + const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: !dev + }); + } + + redirect(303, '/'); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/join/[token]/+page.svelte b/frontend/src/routes/(public)/join/[token]/+page.svelte new file mode 100644 index 0000000..c271495 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.svelte @@ -0,0 +1,46 @@ + + + + Haushalt beitreten — Mealplan + + +{#if data.invalid} +
+
+

+ Einladung ungültig oder abgelaufen +

+

+ Bitte bitte den Einladenden, einen neuen Link zu senden. +

+
+
+{:else} + + +
+ +
+ +
+ + +
+
+

+ Konto erstellen & beitreten +

+ +
+
+
+{/if} diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte new file mode 100644 index 0000000..33c2b89 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte @@ -0,0 +1,41 @@ + + +
+ + + + +
+

+ {householdName} +

+

+ Eingeladen von {inviterName} +

+
+ + +
+

+ Als Mitglied kannst du +

+
    +
  • + + Wochenplan einsehen +
  • +
  • + + Einkaufsliste abhaken +
  • +
  • + + Artikel zur Liste hinzufügen +
  • +
+
+
diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts new file mode 100644 index 0000000..bd6c1b9 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte'; + +describe('HouseholdIdentityPanel', () => { + it('renders household name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('Smith family')).toBeInTheDocument(); + }); + + it('renders inviter name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Sarah/)).toBeInTheDocument(); + }); + + it('renders all three member permissions', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument(); + expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument(); + }); + + it('renders app logo', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('🥗')).toBeInTheDocument(); + }); + + it('permissions list has accessible name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByRole('list', { name: /als mitglied kannst du/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.svelte b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte new file mode 100644 index 0000000..57f5c2c --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte @@ -0,0 +1,113 @@ + + +
+ + {#if form?.errors?.form} +

+ {form.errors.form} +

+ {/if} + + +
+ + + {#if form?.errors?.name} +

+ {form.errors.name} +

+ {/if} +
+ + +
+ + + {#if form?.errors?.email} +

+ {form.errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if form?.errors?.password} +

+ {form.errors.password} +

+ {/if} +
+ + + +
diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts new file mode 100644 index 0000000..2a55f62 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import JoinForm from './JoinForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('JoinForm', () => { + it('renders name, email and password fields', () => { + render(JoinForm); + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders "Haushalt beitreten" submit button', () => { + render(JoinForm); + expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument(); + }); + + it('password field is initially of type password', () => { + render(JoinForm); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches type to text', async () => { + const user = userEvent.setup(); + render(JoinForm); + + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + await user.click(toggle); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text'); + }); + + it('password toggle aria-label updates to "Passwort verbergen" when visible', async () => { + const user = userEvent.setup(); + render(JoinForm); + + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + await user.click(toggle); + expect(screen.getByRole('button', { name: /passwort verbergen/i })).toBeInTheDocument(); + }); + + it('shows form-level error from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: { form: 'Einladung ungültig oder abgelaufen.' } + } + } + }); + expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument(); + }); + + it('shows email-taken error with login link', () => { + render(JoinForm, { + props: { + form: { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + } + } + } + }); + expect(screen.getByText(/bereits registriert/)).toBeInTheDocument(); + }); + + it('pre-fills name and email from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: {}, + name: 'Tom', + email: 'tom@example.com' + } + } + }); + expect(screen.getByLabelText('Name')).toHaveValue('Tom'); + expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com'); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/page.server.test.ts b/frontend/src/routes/(public)/join/[token]/page.server.test.ts new file mode 100644 index 0000000..ae9f9b8 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/page.server.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +vi.mock('$app/environment', () => ({ dev: false })); + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet, POST: mockPost }) +})); + +describe('join page load function', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + function createLoadEvent(token: string) { + return { + params: { token }, + fetch: vi.fn() + } as any; + } + + it('returns householdName and inviterName for valid token', async () => { + mockGet.mockResolvedValue({ + data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } }, + error: undefined + }); + + const result = await load(createLoadEvent('ABC12XYZ')); + + expect(result.invalid).toBeFalsy(); + expect(result.householdName).toBe('Smith family'); + expect(result.inviterName).toBe('Sarah'); + }); + + it('returns invalid:true on 404 (expired/used/unknown token)', async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { status: 404 } + }); + + const result = await load(createLoadEvent('BADTOKEN')); + + expect(result.invalid).toBe(true); + }); +}); + +describe('join page form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createRequest(token: string, formData: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + params: { token }, + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + it('calls POST /v1/invites/{token}/accept with form data', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + try { + await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', { + params: { path: { code: 'ABC12XYZ' } }, + body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' } + }); + }); + + it('sets JSESSIONID cookie and redirects to / on success', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { + headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } + } + }); + + const event = createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + }); + + try { + await actions.default(event); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/'); + } + + expect(event.cookies.set).toHaveBeenCalledWith( + 'JSESSIONID', + 'abc123', + expect.objectContaining({ path: '/', secure: true }) + ); + }); + + it('returns 409 fail with email-taken message on conflict', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 409 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(409); + expect(result.data.errors.email).toContain('registriert'); + }); + + it('returns 400 fail on invalid token (404 from backend)', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 404 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('BADTOKEN', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBeTruthy(); + }); + + it('rejects empty name with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: '', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects invalid email with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'notanemail', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects short password with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'short' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); +});