diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 762464c..bc7bd7c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -17,6 +17,10 @@ jobs: git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git git fetch --depth 1 origin ${GITHUB_SHA} git checkout FETCH_HEAD + git fetch --depth 1 origin master + + - name: Check Flyway migrations + run: bash scripts/check-flyway-migrations.sh origin/master - uses: actions/setup-node@v4 with: diff --git a/backend/build.gradle b/backend/build.gradle index 74380cb..3167ad7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -80,8 +80,15 @@ jacocoTestCoverageVerification { } } +tasks.register('flywayMigrationCheck', Exec) { + group = 'verification' + description = 'Ensure Flyway migrations are unique, immutable, and use new version numbers' + workingDir = rootProject.projectDir + commandLine 'bash', 'scripts/check-flyway-migrations.sh' +} + tasks.named('check').configure { - dependsOn jacocoTestCoverageVerification + dependsOn jacocoTestCoverageVerification, flywayMigrationCheck } tasks.register('hashPassword', JavaExec) { diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index 38700e1..20e53f6 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -38,7 +38,8 @@ public class SecurityConfig { "/api/auth/register", "/api/auth/login", "/api/auth/forgot-password", - "/api/auth/reset-password") + "/api/auth/reset-password", + "/api/auth/confirm-email-change") .permitAll() .requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/payment/swish-info").permitAll() diff --git a/backend/src/main/java/se/bilhalsning/controller/AuthController.java b/backend/src/main/java/se/bilhalsning/controller/AuthController.java index dca99fb..8c647ad 100644 --- a/backend/src/main/java/se/bilhalsning/controller/AuthController.java +++ b/backend/src/main/java/se/bilhalsning/controller/AuthController.java @@ -1,6 +1,7 @@ package se.bilhalsning.controller; import jakarta.validation.Valid; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -11,7 +12,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import se.bilhalsning.dto.AuthResponse; +import se.bilhalsning.dto.ChangeEmailRequest; +import se.bilhalsning.dto.ChangeEmailResponse; import se.bilhalsning.dto.ChangePasswordRequest; +import se.bilhalsning.dto.ConfirmEmailChangeRequest; import se.bilhalsning.dto.ForgotPasswordRequest; import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.ForgotPasswordResponse; @@ -20,6 +24,7 @@ import se.bilhalsning.dto.RegisterRequest; import se.bilhalsning.dto.ResetPasswordRequest; import se.bilhalsning.entity.User; import se.bilhalsning.security.JwtService; +import se.bilhalsning.service.EmailChangeService; import se.bilhalsning.service.PasswordResetService; import se.bilhalsning.service.UserService; @@ -30,11 +35,15 @@ public class AuthController { private final UserService userService; private final PasswordResetService passwordResetService; + private final EmailChangeService emailChangeService; private final JwtService jwtService; private static final String FORGOT_PASSWORD_MESSAGE = "Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."; + private static final String CHANGE_EMAIL_MESSAGE = + "Vi har skickat en bekräftelselänk till din nya e-postadress."; + @PostMapping("/register") public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { userService.createUser(request.email(), request.password()); @@ -71,4 +80,21 @@ public class AuthController { principal.getUsername(), request.currentPassword(), request.newPassword()); return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats.")); } + + @PostMapping("/change-email") + public ResponseEntity changeEmail( + @Valid @RequestBody ChangeEmailRequest request, + @AuthenticationPrincipal UserDetails principal) { + Optional testToken = emailChangeService.requestChange( + principal.getUsername(), request.password(), request.newEmail()); + return ResponseEntity.ok(ChangeEmailResponse.of(CHANGE_EMAIL_MESSAGE, testToken)); + } + + @PostMapping("/confirm-email-change") + public ResponseEntity confirmEmailChange( + @Valid @RequestBody ConfirmEmailChangeRequest request) { + User user = emailChangeService.confirmChange(request.token(), request.password()); + String token = jwtService.generateToken(user.getEmail(), user.getRole()); + return ResponseEntity.ok(new AuthResponse(token)); + } } diff --git a/backend/src/main/java/se/bilhalsning/dto/ChangeEmailRequest.java b/backend/src/main/java/se/bilhalsning/dto/ChangeEmailRequest.java new file mode 100644 index 0000000..334329c --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ChangeEmailRequest.java @@ -0,0 +1,7 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record ChangeEmailRequest( + @NotBlank @Email String newEmail, @NotBlank String password) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/ChangeEmailResponse.java b/backend/src/main/java/se/bilhalsning/dto/ChangeEmailResponse.java new file mode 100644 index 0000000..d3ad457 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ChangeEmailResponse.java @@ -0,0 +1,12 @@ +package se.bilhalsning.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ChangeEmailResponse(String message, String testToken) { + + public static ChangeEmailResponse of(String message, Optional testToken) { + return new ChangeEmailResponse(message, testToken.orElse(null)); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/ConfirmEmailChangeRequest.java b/backend/src/main/java/se/bilhalsning/dto/ConfirmEmailChangeRequest.java new file mode 100644 index 0000000..831f141 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ConfirmEmailChangeRequest.java @@ -0,0 +1,5 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/EmailChangeToken.java b/backend/src/main/java/se/bilhalsning/entity/EmailChangeToken.java new file mode 100644 index 0000000..785cf29 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/EmailChangeToken.java @@ -0,0 +1,106 @@ +package se.bilhalsning.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "email_change_tokens") +public class EmailChangeToken { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "new_email", nullable = false) + private String newEmail; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (this.id == null) { + this.id = UUID.randomUUID(); + } + if (this.createdAt == null) { + this.createdAt = Instant.now(); + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getNewEmail() { + return newEmail; + } + + public void setNewEmail(String newEmail) { + this.newEmail = newEmail; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/se/bilhalsning/exception/EmailChangeTokenInvalidException.java b/backend/src/main/java/se/bilhalsning/exception/EmailChangeTokenInvalidException.java new file mode 100644 index 0000000..cc5bbe4 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/EmailChangeTokenInvalidException.java @@ -0,0 +1,8 @@ +package se.bilhalsning.exception; + +public class EmailChangeTokenInvalidException extends RuntimeException { + + public EmailChangeTokenInvalidException() { + super("Bekräftelselänken är ogiltig eller har gått ut."); + } +} diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index 5fd4054..e4c6c4b 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -29,6 +29,21 @@ public class GlobalExceptionHandler { .body(new ErrorResponse(ex.getMessage())); } + @ExceptionHandler(EmailChangeTokenInvalidException.class) + public ResponseEntity handleEmailChangeTokenInvalid( + EmailChangeTokenInvalidException ex) { + return ResponseEntity + .badRequest() + .body(new ErrorResponse(ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity + .badRequest() + .body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(EmailAlreadyExistsException.class) public ResponseEntity handleEmailAlreadyExists(EmailAlreadyExistsException ex) { return ResponseEntity diff --git a/backend/src/main/java/se/bilhalsning/repository/EmailChangeTokenRepository.java b/backend/src/main/java/se/bilhalsning/repository/EmailChangeTokenRepository.java new file mode 100644 index 0000000..0da43ae --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/repository/EmailChangeTokenRepository.java @@ -0,0 +1,18 @@ +package se.bilhalsning.repository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import se.bilhalsning.entity.EmailChangeToken; + +public interface EmailChangeTokenRepository extends JpaRepository { + + Optional findByTokenHashAndUsedAtIsNull(String tokenHash); + + @Modifying + @Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL") + void deleteUnusedByUserId(@Param("userId") UUID userId); +} diff --git a/backend/src/main/java/se/bilhalsning/service/EmailChangeService.java b/backend/src/main/java/se/bilhalsning/service/EmailChangeService.java new file mode 100644 index 0000000..2a0ef6b --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/EmailChangeService.java @@ -0,0 +1,70 @@ +package se.bilhalsning.service; + +import java.time.Instant; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import se.bilhalsning.entity.EmailChangeToken; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.EmailChangeTokenInvalidException; +import se.bilhalsning.repository.EmailChangeTokenRepository; + +@Service +@RequiredArgsConstructor +public class EmailChangeService { + + private static final long TOKEN_TTL_HOURS = 24; + + private final UserService userService; + private final EmailChangeTokenRepository tokenRepository; + private final EmailService emailService; + private final PasswordResetService passwordResetService; + + @Value("${app.public-base-url:http://localhost:3000}") + private String publicBaseUrl; + + @Value("${app.email-change.expose-token:false}") + private boolean exposeToken; + + @Transactional + public Optional requestChange(String currentEmail, String password, String newEmail) { + User user = userService.authenticate(currentEmail, password); + userService.validateEmailAvailableForChange(user, newEmail); + + String normalizedEmail = newEmail.toLowerCase().trim(); + tokenRepository.deleteUnusedByUserId(user.getId()); + + String rawToken = passwordResetService.generateRawToken(); + EmailChangeToken entity = new EmailChangeToken(); + entity.setUser(user); + entity.setNewEmail(normalizedEmail); + entity.setTokenHash(PasswordResetService.hashToken(rawToken)); + entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600)); + tokenRepository.save(entity); + + String confirmUrl = publicBaseUrl.replaceAll("/$", "") + + "/bekrafta-epost?token=" + + rawToken; + emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl); + + return exposeToken ? Optional.of(rawToken) : Optional.empty(); + } + + @Transactional + public User confirmChange(String rawToken, String password) { + EmailChangeToken token = tokenRepository + .findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken)) + .filter(t -> t.getExpiresAt().isAfter(Instant.now())) + .orElseThrow(EmailChangeTokenInvalidException::new); + + User user = token.getUser(); + userService.authenticate(user.getEmail(), password); + User updated = userService.applyEmailChange(user, token.getNewEmail()); + token.setUsedAt(Instant.now()); + tokenRepository.deleteUnusedByUserId(user.getId()); + tokenRepository.save(token); + return updated; + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/EmailService.java b/backend/src/main/java/se/bilhalsning/service/EmailService.java index 699abc5..4874ecb 100644 --- a/backend/src/main/java/se/bilhalsning/service/EmailService.java +++ b/backend/src/main/java/se/bilhalsning/service/EmailService.java @@ -58,4 +58,39 @@ public class EmailService { throw new IllegalStateException("Kunde inte skicka e-post just nu"); } } + + public void sendEmailChangeConfirmation(String toEmail, String confirmUrl) { + String subject = "Bekräfta din nya e-postadress – BilHej"; + String body = """ + Hej, + + Du har begärt att byta e-postadress för ditt BilHej-konto. + + Öppna länken nedan och ange ditt lösenord för att bekräfta den nya adressen (giltig i 24 timmar): + + %s + + Om du inte begärde detta kan du ignorera det här meddelandet. + + Vänliga hälsningar, + BilHej + """.formatted(confirmUrl); + + if (mailHost == null || mailHost.isBlank() || mailSender == null) { + log.info("SMTP not configured. Email change confirmation link for {}: {}", toEmail, confirmUrl); + return; + } + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailFrom); + message.setTo(toEmail); + message.setSubject(subject); + message.setText(body); + try { + mailSender.send(message); + } catch (MailException ex) { + log.error("Failed to send email change confirmation to {}", toEmail, ex); + throw new IllegalStateException("Kunde inte skicka e-post just nu"); + } + } } diff --git a/backend/src/main/java/se/bilhalsning/service/UserService.java b/backend/src/main/java/se/bilhalsning/service/UserService.java index b0d2f3e..afd57d0 100644 --- a/backend/src/main/java/se/bilhalsning/service/UserService.java +++ b/backend/src/main/java/se/bilhalsning/service/UserService.java @@ -53,4 +53,26 @@ public class UserService { } updatePassword(user, newPassword); } + + public void validateEmailAvailableForChange(User user, String newEmail) { + String normalizedEmail = newEmail.toLowerCase().trim(); + if (normalizedEmail.equals(user.getEmail())) { + throw new IllegalArgumentException("Ny e-postadress måste skilja sig från nuvarande"); + } + if (userRepository.existsByEmail(normalizedEmail)) { + throw new EmailAlreadyExistsException(normalizedEmail); + } + } + + public User applyEmailChange(User user, String newEmail) { + String normalizedEmail = newEmail.toLowerCase().trim(); + if (normalizedEmail.equals(user.getEmail())) { + return user; + } + if (userRepository.existsByEmail(normalizedEmail)) { + throw new EmailAlreadyExistsException(normalizedEmail); + } + user.setEmail(normalizedEmail); + return userRepository.save(user); + } } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 82e15d2..295c50b 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -33,3 +33,5 @@ app: # E2E only: never enable in production (see application-prod.yml). password-reset: expose-token: true + email-change: + expose-token: true diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index e86dfae..2024ab4 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -17,3 +17,5 @@ app: password: ${ADMIN_PASSWORD} password-reset: expose-token: false + email-change: + expose-token: false diff --git a/backend/src/main/resources/db/migration/V10__create_email_change_tokens.sql b/backend/src/main/resources/db/migration/V10__create_email_change_tokens.sql new file mode 100644 index 0000000..b086cbe --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__create_email_change_tokens.sql @@ -0,0 +1,14 @@ +CREATE TABLE email_change_tokens ( + id UUID NOT NULL, + user_id UUID NOT NULL, + new_email VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_email_change_tokens PRIMARY KEY (id), + CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id); +CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash); diff --git a/backend/src/main/resources/db/migration/V9__add_cancelled_order_status.sql b/backend/src/main/resources/db/migration/V9__add_cancelled_order_status.sql new file mode 100644 index 0000000..5f122da --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_cancelled_order_status.sql @@ -0,0 +1,14 @@ +ALTER TABLE orders DROP CONSTRAINT ck_orders_status; + +ALTER TABLE orders + ADD CONSTRAINT ck_orders_status CHECK ( + status IN ( + 'pending_payment', + 'paid', + 'processing', + 'sent', + 'delivered', + 'failed', + 'cancelled' + ) + ); diff --git a/backend/src/test/java/se/bilhalsning/FlywayMigrationFilesTest.java b/backend/src/test/java/se/bilhalsning/FlywayMigrationFilesTest.java new file mode 100644 index 0000000..f7f36be --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/FlywayMigrationFilesTest.java @@ -0,0 +1,45 @@ +package se.bilhalsning; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +class FlywayMigrationFilesTest { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$"); + private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration"); + + @Test + void shouldUseUniqueVersionNumbersAndValidNames() throws IOException { + assertTrue( + Files.isDirectory(MIGRATION_DIR), + () -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath()); + + Set versions = new HashSet<>(); + + try (Stream files = Files.list(MIGRATION_DIR)) { + for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) { + String name = file.getFileName().toString(); + Matcher matcher = VERSION_PATTERN.matcher(name); + assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name); + + int version = Integer.parseInt(matcher.group(1)); + assertFalse( + versions.contains(version), + () -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR); + versions.add(version); + } + } + + assertFalse(versions.isEmpty(), "Expected at least one schema migration"); + } +} diff --git a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java index d26bff0..6bfc7cb 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java @@ -22,6 +22,7 @@ import se.bilhalsning.exception.EmailAlreadyExistsException; import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.security.JwtService; import java.util.Optional; +import se.bilhalsning.service.EmailChangeService; import se.bilhalsning.service.PasswordResetService; import se.bilhalsning.service.UserService; @@ -40,6 +41,9 @@ class AuthControllerTest { @MockitoBean private PasswordResetService passwordResetService; + @MockitoBean + private EmailChangeService emailChangeService; + @MockitoBean private JwtService jwtService; @@ -223,4 +227,42 @@ class AuthControllerTest { "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) .andExpect(status().isForbidden()); } + + @Test + @WithMockUser(username = "user@example.com") + void shouldReturn200WhenChangeEmailRequestSucceeds() throws Exception { + when(emailChangeService.requestChange("user@example.com", "password123", "new@example.com")) + .thenReturn(Optional.of("test-token")); + + mockMvc.perform(post("/api/auth/change-email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message") + .value("Vi har skickat en bekräftelselänk till din nya e-postadress.")) + .andExpect(jsonPath("$.testToken").value("test-token")); + } + + @Test + void shouldReturn200AndNewTokenWhenConfirmEmailChangeSucceeds() throws Exception { + User user = new User(); + user.setEmail("new@example.com"); + user.setRole("user"); + when(emailChangeService.confirmChange("confirm-token", "password123")).thenReturn(user); + when(jwtService.generateToken("new@example.com", "user")).thenReturn("new-jwt-token"); + + mockMvc.perform(post("/api/auth/confirm-email-change") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"confirm-token\",\"password\":\"password123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value("new-jwt-token")); + } + + @Test + void shouldRejectChangeEmailWithoutAuth() throws Exception { + mockMvc.perform(post("/api/auth/change-email") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) + .andExpect(status().isForbidden()); + } } diff --git a/backend/src/test/java/se/bilhalsning/service/AccountSettingsIntegrationTest.java b/backend/src/test/java/se/bilhalsning/service/AccountSettingsIntegrationTest.java new file mode 100644 index 0000000..8cc1335 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/AccountSettingsIntegrationTest.java @@ -0,0 +1,100 @@ +package se.bilhalsning.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import se.bilhalsning.exception.InvalidCredentialsException; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AccountSettingsIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private EmailChangeService emailChangeService; + + @Test + void shouldChangePasswordAndChangeBack() { + String email = "pw-settings-" + UUID.randomUUID() + "@bilhej.se"; + String originalPassword = "original1234"; + String changedPassword = "changed12345"; + + userService.createUser(email, originalPassword); + + userService.changePassword(email, originalPassword, changedPassword); + assertDoesNotThrow(() -> userService.authenticate(email, changedPassword)); + assertThrows( + InvalidCredentialsException.class, + () -> userService.authenticate(email, originalPassword)); + + userService.changePassword(email, changedPassword, originalPassword); + assertDoesNotThrow(() -> userService.authenticate(email, originalPassword)); + assertThrows( + InvalidCredentialsException.class, + () -> userService.authenticate(email, changedPassword)); + } + + @Test + void shouldChangeEmailAfterConfirmationAndChangeBack() { + String suffix = UUID.randomUUID().toString(); + String originalEmail = "email-settings-" + suffix + "@bilhej.se"; + String tempEmail = "email-settings-" + suffix + "-new@bilhej.se"; + String password = "password1234"; + + userService.createUser(originalEmail, password); + + var firstToken = emailChangeService + .requestChange(originalEmail, password, tempEmail) + .orElseThrow(); + emailChangeService.confirmChange(firstToken, password); + + assertEquals(tempEmail, userService.findByEmail(tempEmail).orElseThrow().getEmail()); + assertThrows( + InvalidCredentialsException.class, + () -> userService.authenticate(originalEmail, password)); + + var secondToken = emailChangeService + .requestChange(tempEmail, password, originalEmail) + .orElseThrow(); + emailChangeService.confirmChange(secondToken, password); + + assertEquals( + originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail()); + assertThrows( + InvalidCredentialsException.class, () -> userService.authenticate(tempEmail, password)); + } + + @Test + void shouldRejectEmailChangeConfirmWhenPasswordWrong() { + String suffix = UUID.randomUUID().toString(); + String originalEmail = "email-wrongpw-" + suffix + "@bilhej.se"; + String tempEmail = "email-wrongpw-" + suffix + "-new@bilhej.se"; + String password = "password1234"; + + userService.createUser(originalEmail, password); + + var token = emailChangeService + .requestChange(originalEmail, password, tempEmail) + .orElseThrow(); + + assertThrows( + InvalidCredentialsException.class, + () -> emailChangeService.confirmChange(token, "wrongpassword")); + + assertEquals( + originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail()); + assertThrows( + InvalidCredentialsException.class, + () -> userService.authenticate(tempEmail, password)); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/EmailChangeServiceTest.java b/backend/src/test/java/se/bilhalsning/service/EmailChangeServiceTest.java new file mode 100644 index 0000000..1ee32d7 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/EmailChangeServiceTest.java @@ -0,0 +1,140 @@ +package se.bilhalsning.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import se.bilhalsning.entity.EmailChangeToken; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.EmailChangeTokenInvalidException; +import se.bilhalsning.exception.InvalidCredentialsException; +import se.bilhalsning.repository.EmailChangeTokenRepository; + +@ExtendWith(MockitoExtension.class) +class EmailChangeServiceTest { + + @Mock + private UserService userService; + + @Mock + private EmailChangeTokenRepository tokenRepository; + + @Mock + private EmailService emailService; + + @Mock + private PasswordResetService passwordResetService; + + @InjectMocks + private EmailChangeService emailChangeService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(emailChangeService, "publicBaseUrl", "http://localhost:3000"); + ReflectionTestUtils.setField(emailChangeService, "exposeToken", true); + } + + @Test + void shouldSendConfirmationEmailWhenRequestIsValid() { + User user = new User(); + user.setEmail("old@example.com"); + user.setRole("user"); + + when(userService.authenticate("old@example.com", "password123")).thenReturn(user); + when(passwordResetService.generateRawToken()).thenReturn("raw-token"); + + Optional testToken = + emailChangeService.requestChange("old@example.com", "password123", "new@example.com"); + + assertEquals(Optional.of("raw-token"), testToken); + verify(userService).validateEmailAvailableForChange(user, "new@example.com"); + verify(tokenRepository).deleteUnusedByUserId(user.getId()); + verify(tokenRepository).save(any(EmailChangeToken.class)); + verify(emailService) + .sendEmailChangeConfirmation( + eq("new@example.com"), + eq("http://localhost:3000/bekrafta-epost?token=raw-token")); + } + + @Test + void shouldRejectRequestWhenPasswordWrong() { + when(userService.authenticate("old@example.com", "wrong")) + .thenThrow(new InvalidCredentialsException()); + + assertThrows( + InvalidCredentialsException.class, + () -> emailChangeService.requestChange("old@example.com", "wrong", "new@example.com")); + + verify(tokenRepository, never()).save(any(EmailChangeToken.class)); + verify(emailService, never()).sendEmailChangeConfirmation(any(), any()); + } + + @Test + void shouldConfirmEmailChangeWhenTokenIsValid() { + User user = new User(); + user.setEmail("old@example.com"); + user.setRole("user"); + + EmailChangeToken token = new EmailChangeToken(); + token.setUser(user); + token.setNewEmail("new@example.com"); + token.setTokenHash(PasswordResetService.hashToken("raw-token")); + token.setExpiresAt(java.time.Instant.now().plusSeconds(3600)); + + when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token"))) + .thenReturn(Optional.of(token)); + when(userService.authenticate("old@example.com", "password123")).thenReturn(user); + when(userService.applyEmailChange(user, "new@example.com")).thenReturn(user); + + User result = emailChangeService.confirmChange("raw-token", "password123"); + + assertEquals(user, result); + verify(userService).authenticate("old@example.com", "password123"); + verify(tokenRepository).deleteUnusedByUserId(user.getId()); + verify(tokenRepository).save(token); + } + + @Test + void shouldRejectConfirmWhenPasswordWrong() { + User user = new User(); + user.setEmail("old@example.com"); + + EmailChangeToken token = new EmailChangeToken(); + token.setUser(user); + token.setNewEmail("new@example.com"); + token.setTokenHash(PasswordResetService.hashToken("raw-token")); + token.setExpiresAt(java.time.Instant.now().plusSeconds(3600)); + + when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token"))) + .thenReturn(Optional.of(token)); + when(userService.authenticate("old@example.com", "wrong")) + .thenThrow(new InvalidCredentialsException()); + + assertThrows( + InvalidCredentialsException.class, + () -> emailChangeService.confirmChange("raw-token", "wrong")); + + verify(userService, never()).applyEmailChange(any(), any()); + } + + @Test + void shouldRejectConfirmWhenTokenInvalid() { + when(tokenRepository.findByTokenHashAndUsedAtIsNull(any())).thenReturn(Optional.empty()); + + assertThrows( + EmailChangeTokenInvalidException.class, + () -> emailChangeService.confirmChange("bad-token", "password123")); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java index f05d671..b53311c 100644 --- a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java @@ -203,4 +203,35 @@ class UserServiceTest { verify(userRepository, never()).save(any(User.class)); } + + @Test + void shouldApplyEmailChangeWhenNewEmailAvailable() { + User user = new User(); + user.setEmail("old@example.com"); + user.setPasswordHash("hash"); + user.setRole("user"); + + when(userRepository.existsByEmail("new@example.com")).thenReturn(false); + when(userRepository.save(user)).thenReturn(user); + + User result = userService.applyEmailChange(user, "new@example.com"); + + assertEquals("new@example.com", result.getEmail()); + verify(userRepository).save(user); + } + + @Test + void shouldRejectApplyEmailChangeWhenNewEmailTaken() { + User user = new User(); + user.setEmail("old@example.com"); + user.setPasswordHash("hash"); + + when(userRepository.existsByEmail("taken@example.com")).thenReturn(true); + + assertThrows( + EmailAlreadyExistsException.class, + () -> userService.applyEmailChange(user, "taken@example.com")); + + verify(userRepository, never()).save(any(User.class)); + } } diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index bb80da3..1626716 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -1,3 +1,5 @@ app: password-reset: expose-token: true + email-change: + expose-token: true diff --git a/frontend/e2e/account-settings.spec.ts b/frontend/e2e/account-settings.spec.ts new file mode 100644 index 0000000..e9d535e --- /dev/null +++ b/frontend/e2e/account-settings.spec.ts @@ -0,0 +1,182 @@ +import { test, expect, type Page, type APIRequestContext } from '@playwright/test' +import { + clearMailpit, + countMessagesTo, + waitForEmailChangeToken, +} from './helpers/mailpit' + +test.describe('Account settings', () => { + test('can change password and change back', async ({ page, request }) => { + const email = `pw-change-${Date.now()}@bilhej.se` + const originalPassword = 'original1234' + const changedPassword = 'changed12345' + + await registerUser(request, email, originalPassword) + await loginViaUi(page, email, originalPassword) + + await changePasswordViaUi(page, originalPassword, changedPassword) + await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible() + + await logoutViaHeader(page) + await expectLoginFails(page, email, originalPassword) + + await loginViaUi(page, email, changedPassword) + + await changePasswordViaUi(page, changedPassword, originalPassword) + await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible() + + await logoutViaHeader(page) + await expectLoginFails(page, email, changedPassword) + await loginViaUi(page, email, originalPassword) + }) + + test('can change email after confirming link sent to new address', async ({ + page, + request, + }) => { + const suffix = Date.now() + const originalEmail = `email-change-${suffix}@bilhej.se` + const tempEmail = `email-change-${suffix}-new@bilhej.se` + const password = 'password1234' + + await clearMailpit(request) + await registerUser(request, originalEmail, password) + await loginViaUi(page, originalEmail, password) + + await page.goto('/andra-epost') + await changeEmailViaUi(page, tempEmail, password) + await expect( + page.getByText( + 'Vi har skickat en bekräftelselänk till din nya e-postadress.', + ), + ).toBeVisible() + expect(await countMessagesTo(request, tempEmail)).toBe(1) + expect(await countMessagesTo(request, originalEmail)).toBe(0) + + const token = await waitForEmailChangeToken(request, tempEmail, { + publicBaseUrl: 'http://frontend', + }) + await confirmEmailChangeViaUi(page, token, password) + await expect( + page.getByText('Din e-postadress har uppdaterats.'), + ).toBeVisible() + await expect(page.locator('header')).toContainText(tempEmail) + + await clearMailpit(request) + await page.goto('/andra-epost') + await changeEmailViaUi(page, originalEmail, password) + await expect( + page.getByText( + 'Vi har skickat en bekräftelselänk till din nya e-postadress.', + ), + ).toBeVisible() + expect(await countMessagesTo(request, originalEmail)).toBe(1) + + const restoreToken = await waitForEmailChangeToken(request, originalEmail, { + publicBaseUrl: 'http://frontend', + }) + await confirmEmailChangeViaUi(page, restoreToken, password) + await expect( + page.getByText('Din e-postadress har uppdaterats.'), + ).toBeVisible() + await expect(page.locator('header')).toContainText(originalEmail) + }) + + test('does not change email when confirm password is wrong', async ({ + page, + request, + }) => { + const suffix = Date.now() + const originalEmail = `email-wrongpw-e2e-${suffix}@bilhej.se` + const tempEmail = `email-wrongpw-e2e-${suffix}-new@bilhej.se` + const password = 'password1234' + + await clearMailpit(request) + await registerUser(request, originalEmail, password) + await loginViaUi(page, originalEmail, password) + + await page.goto('/andra-epost') + await changeEmailViaUi(page, tempEmail, password) + + const token = await waitForEmailChangeToken(request, tempEmail, { + publicBaseUrl: 'http://frontend', + }) + + await page.goto(`/bekrafta-epost?token=${token}`) + await page.locator('#password').fill('wrongpassword') + await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click() + + await expect(page.getByText('Lösenordet är felaktigt')).toBeVisible() + await expect(page.locator('header')).toContainText(originalEmail) + + const login = await request.post('/api/auth/login', { + data: { email: originalEmail, password }, + }) + expect(login.ok()).toBeTruthy() + + const loginWithNewEmail = await request.post('/api/auth/login', { + data: { email: tempEmail, password }, + }) + expect(loginWithNewEmail.ok()).toBeFalsy() + }) +}) + +async function registerUser( + request: APIRequestContext, + email: string, + password: string, +) { + const response = await request.post('/api/auth/register', { + data: { email, password }, + }) + expect(response.ok()).toBeTruthy() +} + +async function loginViaUi(page: Page, email: string, password: string) { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill(email) + await page.getByLabel('Lösenord').fill(password) + await page.getByRole('button', { name: 'Logga in' }).click() + await expect(page).toHaveURL('/') +} + +async function expectLoginFails(page: Page, email: string, password: string) { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill(email) + await page.getByLabel('Lösenord').fill(password) + await page.getByRole('button', { name: 'Logga in' }).click() + await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible() +} + +async function logoutViaHeader(page: Page) { + await page.locator('header').getByRole('button', { name: 'Logga ut' }).click() + await expect(page).toHaveURL('/') +} + +async function changePasswordViaUi( + page: Page, + currentPassword: string, + newPassword: string, +) { + await page.goto('/andra-losenord') + await page.locator('#current-password').fill(currentPassword) + await page.locator('#password').fill(newPassword) + await page.locator('#confirm-password').fill(newPassword) + await page.getByRole('button', { name: 'Spara nytt lösenord' }).click() +} + +async function changeEmailViaUi(page: Page, newEmail: string, password: string) { + await page.locator('#new-email').fill(newEmail) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Spara ny e-postadress' }).click() +} + +async function confirmEmailChangeViaUi( + page: Page, + token: string, + password: string, +) { + await page.goto(`/bekrafta-epost?token=${token}`) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click() +} diff --git a/frontend/e2e/auth-guards.spec.ts b/frontend/e2e/auth-guards.spec.ts index b36149f..f0e7e4c 100644 --- a/frontend/e2e/auth-guards.spec.ts +++ b/frontend/e2e/auth-guards.spec.ts @@ -25,6 +25,22 @@ test.describe('Auth guards', () => { await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() }) + test('redirects unauthenticated user from /andra-losenord to /logga-in', async ({ + page, + }) => { + await page.goto('/andra-losenord') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-losenord/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('redirects unauthenticated user from /andra-epost to /logga-in', async ({ + page, + }) => { + await page.goto('/andra-epost') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-epost/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + test('redirects authenticated user from /logga-in to home', async ({ page, }) => { diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts index b95d83e..e8f8bfc 100644 --- a/frontend/e2e/header-auth.spec.ts +++ b/frontend/e2e/header-auth.spec.ts @@ -143,8 +143,70 @@ test.describe('Header auth state', () => { header.getByRole('link', { name: 'Admin' }), ).not.toBeVisible() }) + + test('shows settings button when authenticated', async ({ page }) => { + await authenticateUser(page) + + const header = page.locator('header') + await expect( + header.getByRole('button', { name: 'Inställningar' }), + ).toBeVisible() + }) + + test('settings menu links to change email and password pages', async ({ + page, + }) => { + await authenticateUser(page) + + const header = page.locator('header') + const settingsButton = header.getByRole('button', { name: 'Inställningar' }) + await settingsButton.click() + + const menu = header.getByRole('menu') + await expect( + menu.getByRole('menuitem', { name: 'Byt e-postadress' }), + ).toHaveAttribute('href', '/andra-epost') + await expect( + menu.getByRole('menuitem', { name: 'Byt lösenord' }), + ).toHaveAttribute('href', '/andra-losenord') + }) + + test('highlights settings button on change password page', async ({ + page, + }) => { + await authenticateUser(page) + await page.goto('/andra-losenord') + + const settingsButton = page + .locator('header') + .getByRole('button', { name: 'Inställningar' }) + await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/) + await expect( + page.getByRole('heading', { name: 'Byt lösenord' }), + ).toBeVisible() + }) + + test('highlights settings button on change email page', async ({ page }) => { + await authenticateUser(page) + await page.goto('/andra-epost') + + const settingsButton = page + .locator('header') + .getByRole('button', { name: 'Inställningar' }) + await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/) + await expect( + page.getByRole('heading', { name: 'Byt e-postadress' }), + ).toBeVisible() + }) }) +async function authenticateUser(page: import('@playwright/test').Page) { + const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + await page.goto('/') +} + function makeJwt(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const body = btoa(JSON.stringify(payload)) diff --git a/frontend/e2e/helpers/mailpit.ts b/frontend/e2e/helpers/mailpit.ts index 74326ea..c8c3dba 100644 --- a/frontend/e2e/helpers/mailpit.ts +++ b/frontend/e2e/helpers/mailpit.ts @@ -100,6 +100,65 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | null return null } +export async function waitForEmailChangeToken( + request: APIRequestContext, + recipientEmail: string, + options: { timeoutMs?: number; publicBaseUrl?: string } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 20_000 + const deadline = Date.now() + timeoutMs + const normalizedRecipient = recipientEmail.toLowerCase().trim() + + while (Date.now() < deadline) { + const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`) + if (!listResponse.ok()) { + await sleep(500) + continue + } + + const list = (await listResponse.json()) as MailpitMessagesResponse + for (const summary of list.messages ?? []) { + const matchesRecipient = summary.To?.some( + (to) => to.Address.toLowerCase() === normalizedRecipient, + ) + if (!matchesRecipient) continue + + const detailResponse = await request.get( + `${mailpitApiBase}/api/v1/message/${summary.ID}`, + ) + if (!detailResponse.ok()) continue + + const detail = (await detailResponse.json()) as MailpitMessageDetail + const body = detail.Text ?? detail.HTML ?? '' + const token = extractEmailChangeToken(body, options.publicBaseUrl) + if (token) return token + } + + await sleep(500) + } + + throw new Error( + `No email change confirmation for ${recipientEmail} in Mailpit within ${timeoutMs}ms`, + ) +} + +function extractEmailChangeToken(body: string, publicBaseUrl?: string): string | null { + const pathPattern = /\/bekrafta-epost\?token=([A-Za-z0-9_-]+)/ + const pathMatch = body.match(pathPattern) + if (pathMatch) return pathMatch[1] + + if (publicBaseUrl) { + const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const fullPattern = new RegExp( + `${escaped}/bekrafta-epost\\?token=([A-Za-z0-9_-]+)`, + ) + const fullMatch = body.match(fullPattern) + if (fullMatch) return fullMatch[1] + } + + return null +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index 7aea7fd..77e1ad4 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -30,6 +30,11 @@ function createTestRouter() { name: 'change-password', component: { template: '
Change password
' }, }, + { + path: '/andra-epost', + name: 'change-email', + component: { template: '
Change email
' }, + }, { path: '/admin', name: 'admin', @@ -142,7 +147,7 @@ describe('AppHeader', () => { it('shows logout button', () => { const { wrapper } = mountAuthenticated() - const logoutButton = wrapper.find('button') + const logoutButton = wrapper.find('.app-header__logout') expect(logoutButton.exists()).toBe(true) expect(logoutButton.text()).toBe('Logga ut') }) @@ -171,14 +176,51 @@ describe('AppHeader', () => { expect(ordersLink?.text()).toBe('Mina beställningar') }) - it('shows change password link', () => { + it('shows settings menu with account links', async () => { const { wrapper } = mountAuthenticated() - const links = wrapper.findAll('a') - const changeLink = links.find( - (a) => a.attributes('href') === '/andra-losenord', - ) - expect(changeLink).toBeTruthy() - expect(changeLink?.text()).toBe('Byt lösenord') + expect(wrapper.text()).not.toContain('Byt lösenord') + + await wrapper.find('.app-header__settings-trigger').trigger('click') + + const links = wrapper.findAll('.app-header__settings-item') + expect(links).toHaveLength(2) + expect(links[0].attributes('href')).toBe('/andra-epost') + expect(links[0].text()).toBe('Byt e-postadress') + expect(links[1].attributes('href')).toBe('/andra-losenord') + expect(links[1].text()).toBe('Byt lösenord') + }) + + it('highlights settings trigger on change password page', async () => { + const { wrapper, router } = mountAuthenticated() + await router.push('/andra-losenord') + await router.isReady() + await wrapper.vm.$nextTick() + + expect( + wrapper.find('.app-header__settings-trigger').classes(), + ).toContain('app-header__settings-trigger--active') + }) + + it('highlights settings trigger on change email page', async () => { + const { wrapper, router } = mountAuthenticated() + await router.push('/andra-epost') + await router.isReady() + await wrapper.vm.$nextTick() + + expect( + wrapper.find('.app-header__settings-trigger').classes(), + ).toContain('app-header__settings-trigger--active') + }) + + it('does not highlight settings trigger on other pages', async () => { + const { wrapper, router } = mountAuthenticated() + await router.push('/orders') + await router.isReady() + await wrapper.vm.$nextTick() + + expect( + wrapper.find('.app-header__settings-trigger').classes(), + ).not.toContain('app-header__settings-trigger--active') }) it('does not show admin link for regular user', () => { @@ -210,7 +252,7 @@ describe('AppHeader', () => { resolve() }) }) - await wrapper.find('button').trigger('click') + await wrapper.find('.app-header__logout').trigger('click') await navigationDone expect(auth.isAuthenticated).toBe(false) diff --git a/frontend/src/__tests__/ChangeEmailPage.spec.ts b/frontend/src/__tests__/ChangeEmailPage.spec.ts new file mode 100644 index 0000000..fee86cc --- /dev/null +++ b/frontend/src/__tests__/ChangeEmailPage.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import { setActivePinia, createPinia } from 'pinia' +import ChangeEmailPage from '@/pages/ChangeEmailPage.vue' +import { useAuthStore } from '@/stores/authStore' + +function makeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + return `${header}.${body}.test-sig` +} + +describe('ChangeEmailPage', () => { + it('renders current email and form fields', () => { + const pinia = createPinia() + setActivePinia(pinia) + localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' })) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/andra-epost', component: ChangeEmailPage }], + }) + + const wrapper = mount(ChangeEmailPage, { + global: { plugins: [router, pinia] }, + }) + + expect(wrapper.text()).toContain('Byt e-postadress') + expect(wrapper.text()).toContain('test@bilhej.se') + expect(wrapper.find('#new-email').exists()).toBe(true) + expect(wrapper.find('#password').exists()).toBe(true) + }) + + it('shows auth email from store', () => { + const pinia = createPinia() + setActivePinia(pinia) + localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' })) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/andra-epost', component: ChangeEmailPage }], + }) + + mount(ChangeEmailPage, { + global: { plugins: [router, pinia] }, + }) + + expect(useAuthStore().email).toBe('user@example.com') + }) +}) diff --git a/frontend/src/__tests__/ConfirmEmailChangePage.spec.ts b/frontend/src/__tests__/ConfirmEmailChangePage.spec.ts new file mode 100644 index 0000000..7a4c7a9 --- /dev/null +++ b/frontend/src/__tests__/ConfirmEmailChangePage.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import { setActivePinia, createPinia } from 'pinia' +import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue' + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/bekrafta-epost', + name: 'confirm-email-change', + component: ConfirmEmailChangePage, + }, + ], + }) +} + +async function mountPage(initialPath: string) { + const pinia = createPinia() + setActivePinia(pinia) + const router = createTestRouter() + await router.push(initialPath) + await router.isReady() + const wrapper = mount(ConfirmEmailChangePage, { + global: { plugins: [router, pinia] }, + }) + await flushPromises() + return { wrapper, router } +} + +describe('ConfirmEmailChangePage', () => { + it('shows password form when token is present', async () => { + const { wrapper } = await mountPage('/bekrafta-epost?token=test-token') + + expect(wrapper.text()).toContain('Bekräfta e-postadress') + expect(wrapper.text()).toContain('Ange ditt lösenord') + expect(wrapper.find('#password').exists()).toBe(true) + expect(wrapper.find('button[type="submit"]').text()).toBe( + 'Bekräfta ny e-postadress', + ) + }) + + it('shows error when token is missing', async () => { + const { wrapper } = await mountPage('/bekrafta-epost') + + expect(wrapper.text()).toContain('Bekräftelselänken saknar en giltig kod.') + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index 257431a..0e1a6d5 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -52,6 +52,13 @@ describe('Router', () => { expect(router.currentRoute.value.name).toBe('change-password') }) + it('resolves /andra-epost to ChangeEmailPage when authenticated', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/andra-epost') + await router.isReady() + expect(router.currentRoute.value.name).toBe('change-email') + }) + it('resolves /admin to AdminPage for admin user', async () => { localStorage.setItem('auth_token', makeJwt({ role: 'admin' })) await router.push('/admin') @@ -93,6 +100,19 @@ describe('Router guards', () => { expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord') }) + it('redirects unauthenticated user from /andra-epost to /logga-in', async () => { + await router.push('/andra-epost') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/andra-epost') + }) + + it('resolves /bekrafta-epost to ConfirmEmailChangePage', async () => { + await router.push('/bekrafta-epost?token=abc') + await router.isReady() + expect(router.currentRoute.value.name).toBe('confirm-email-change') + }) + it('redirects unauthenticated user from /admin to /logga-in', async () => { await router.push('/admin') await router.isReady() diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c621ec6..5dc49b5 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -25,6 +25,11 @@ export interface MessageResponse { message: string } +/** Optional testToken is returned only when backend expose-token is enabled (E2E). */ +export interface ChangeEmailResponse extends MessageResponse { + testToken?: string +} + /** Optional testToken is returned only when backend expose-token is enabled (E2E). */ export interface ForgotPasswordResponse extends MessageResponse { testToken?: string @@ -56,3 +61,23 @@ export function changePassword( body: JSON.stringify({ currentPassword, newPassword }), }) } + +export function changeEmail( + newEmail: string, + password: string, +): Promise { + return request('/auth/change-email', { + method: 'POST', + body: JSON.stringify({ newEmail, password }), + }) +} + +export function confirmEmailChange( + token: string, + password: string, +): Promise { + return request('/auth/confirm-email-change', { + method: 'POST', + body: JSON.stringify({ token, password }), + }) +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index ced8ba1..0a15ea3 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,14 +1,44 @@ @@ -150,6 +226,73 @@ function handleLogout() { padding: 0 0.5rem; } +.app-header__settings { + position: relative; +} + +.app-header__settings-trigger { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0.875rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + cursor: pointer; + transition: + color var(--transition-fast), + border-color var(--transition-fast), + background var(--transition-fast); +} + +.app-header__settings-trigger:hover, +.app-header__settings-trigger--active, +.app-header__settings-trigger[aria-expanded='true'] { + color: var(--color-primary-dark); + border-color: #bfdbfe; + background: var(--color-primary-soft); +} + +.app-header__settings-chevron { + width: 1rem; + height: 1rem; + transition: transform var(--transition-fast); +} + +.app-header__settings-chevron--open { + transform: rotate(180deg); +} + +.app-header__settings-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + min-width: 12rem; + padding: 0.35rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +.app-header__settings-item { + display: block; + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-ink); + text-decoration: none; + border-radius: var(--radius-md); + transition: background var(--transition-fast); +} + +.app-header__settings-item:hover { + background: var(--color-border-light); +} + .app-header__logout { background: none; border: 1px solid var(--color-border); diff --git a/frontend/src/pages/ChangeEmailPage.vue b/frontend/src/pages/ChangeEmailPage.vue new file mode 100644 index 0000000..02778c4 --- /dev/null +++ b/frontend/src/pages/ChangeEmailPage.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/pages/ConfirmEmailChangePage.vue b/frontend/src/pages/ConfirmEmailChangePage.vue new file mode 100644 index 0000000..d9915d9 --- /dev/null +++ b/frontend/src/pages/ConfirmEmailChangePage.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1ac2844..cb64085 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,8 @@ import LoginPage from '@/pages/LoginPage.vue' import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue' import ResetPasswordPage from '@/pages/ResetPasswordPage.vue' import ChangePasswordPage from '@/pages/ChangePasswordPage.vue' +import ChangeEmailPage from '@/pages/ChangeEmailPage.vue' +import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue' import OrdersPage from '@/pages/OrdersPage.vue' import AdminPage from '@/pages/AdminPage.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue' @@ -40,6 +42,12 @@ const router = createRouter({ component: ChangePasswordPage, meta: { requiresAuth: true }, }, + { + path: '/andra-epost', + name: 'change-email', + component: ChangeEmailPage, + meta: { requiresAuth: true }, + }, { path: '/admin', name: 'admin', @@ -76,6 +84,11 @@ const router = createRouter({ component: ResetPasswordPage, meta: { guestOnly: true }, }, + { + path: '/bekrafta-epost', + name: 'confirm-email-change', + component: ConfirmEmailChangePage, + }, { path: '/om', name: 'about', diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index c588df6..274a61d 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { register, login } from '@/api/auth' +import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' import { parseJwtPayload } from '@/utils/jwt' export const useAuthStore = defineStore('auth', () => { @@ -43,6 +43,22 @@ export const useAuthStore = defineStore('auth', () => { setToken(response.token) } + async function changeUserEmail( + newEmail: string, + password: string, + ): Promise { + const response = await changeEmail(newEmail, password) + return response.testToken + } + + async function confirmUserEmailChange( + token: string, + password: string, + ): Promise { + const response = await confirmEmailChange(token, password) + setToken(response.token) + } + function logout() { clearToken() } @@ -55,6 +71,8 @@ export const useAuthStore = defineStore('auth', () => { isAdmin, registerUser, loginUser, + changeUserEmail, + confirmUserEmailChange, logout, } }) diff --git a/scripts/check-flyway-migrations.sh b/scripts/check-flyway-migrations.sh new file mode 100755 index 0000000..80e401a --- /dev/null +++ b/scripts/check-flyway-migrations.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Validates Flyway schema migrations under db/migration/. +# - No duplicate version numbers in the repo +# - Migrations already on the base branch are immutable +# - New migrations must use a version number greater than the highest on the base branch +# +# Usage: scripts/check-flyway-migrations.sh [base-ref] +# Default base ref: origin/master (override with FLYWAY_BASE_REF) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATION_DIR="backend/src/main/resources/db/migration" +BASE_REF="${1:-${FLYWAY_BASE_REF:-origin/master}}" + +cd "$ROOT" + +if [[ ! -d "$MIGRATION_DIR" ]]; then + echo "Missing migration directory: $MIGRATION_DIR" >&2 + exit 1 +fi + +declare -A seen_versions=() +max_local=0 + +for file in "$MIGRATION_DIR"/V*.sql; do + [[ -e "$file" ]] || continue + name="$(basename "$file")" + if [[ ! "$name" =~ ^V([0-9]+)__.+\.sql$ ]]; then + echo "ERROR: Invalid migration filename (expected V__description.sql): $name" >&2 + exit 1 + fi + version="${BASH_REMATCH[1]}" + version=$((10#$version)) + if [[ -n "${seen_versions[$version]:-}" ]]; then + echo "ERROR: Duplicate Flyway version V${version}:" >&2 + echo " ${seen_versions[$version]}" >&2 + echo " $name" >&2 + exit 1 + fi + seen_versions[$version]="$name" + if (( version > max_local )); then + max_local=$version + fi +done + +if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + echo "Flyway check: unique versions OK (max V${max_local}). Skipping base-branch rules (${BASE_REF} not found)." + exit 0 +fi + +max_base=0 +while IFS= read -r path; do + [[ -z "$path" ]] && continue + name="$(basename "$path")" + if [[ "$name" =~ ^V([0-9]+)__.+\.sql$ ]]; then + version="${BASH_REMATCH[1]}" + version=$((10#$version)) + if (( version > max_base )); then + max_base=$version + fi + fi +done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATION_DIR" 2>/dev/null | grep -E '/V[0-9]+__.*\.sql$' || true) + +while IFS= read -r file; do + [[ -z "$file" ]] && continue + if git cat-file -e "$BASE_REF:$file" 2>/dev/null; then + if ! git diff --quiet "$BASE_REF" -- "$file"; then + echo "ERROR: Modified existing migration (immutable after merge): $file" >&2 + echo "Create a new V$((max_base + 1))__... migration instead of editing $file." >&2 + exit 1 + fi + else + name="$(basename "$file")" + version="${name#V}" + version="${version%%__*}" + version=$((10#$version)) + if (( version <= max_base )); then + echo "ERROR: New migration $name uses V${version}, but ${BASE_REF} already has migrations up to V${max_base}." >&2 + echo "Use V$((max_base + 1))__your_description.sql or rebase and renumber." >&2 + exit 1 + fi + fi +done < <(git ls-files "$MIGRATION_DIR"/V*.sql) + +echo "Flyway check OK (local max V${max_local}, ${BASE_REF} max V${max_base})." diff --git a/scripts/next-flyway-version.sh b/scripts/next-flyway-version.sh new file mode 100755 index 0000000..99e8fe4 --- /dev/null +++ b/scripts/next-flyway-version.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Prints the next available Flyway version for db/migration/. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATION_DIR="$ROOT/backend/src/main/resources/db/migration" + +max=0 +for file in "$MIGRATION_DIR"/V*.sql; do + [[ -e "$file" ]] || continue + name="$(basename "$file")" + if [[ "$name" =~ ^V([0-9]+)__ ]]; then + version="${BASH_REMATCH[1]}" + version=$((10#$version)) + if (( version > max )); then + max=$version + fi + fi +done + +next=$((max + 1)) +echo "Next migration: V${next}__your_description.sql"