Compare commits
No commits in common. "210ac87ede81c8e742b0dfcbeeafd3d7cb4fc16c" and "c03b5a140181a90a0e0a0354c0dee633421446f9" have entirely different histories.
210ac87ede
...
c03b5a1401
29 changed files with 14 additions and 1254 deletions
15
.env.example
15
.env.example
|
|
@ -1,25 +1,16 @@
|
||||||
# BilHej Environment Variables
|
# BilHej Environment Variables
|
||||||
# Copy this file to .env and fill in your keys.
|
# Copy this file to .env and fill in your keys.
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
#
|
|
||||||
# Docker Compose reads .env from the project root automatically.
|
|
||||||
|
|
||||||
# ---------- PostgreSQL ----------
|
# PostgreSQL
|
||||||
POSTGRES_DB=bilhej
|
POSTGRES_DB=bilhej
|
||||||
POSTGRES_USER=bilhej
|
POSTGRES_USER=bilhej
|
||||||
POSTGRES_PASSWORD=change_me
|
POSTGRES_PASSWORD=change_me
|
||||||
|
|
||||||
# ---------- JWT ----------
|
# JWT
|
||||||
# Generate a secure random secret:
|
|
||||||
# openssl rand -hex 32
|
|
||||||
JWT_SECRET=change_me_to_a_random_64_char_string
|
JWT_SECRET=change_me_to_a_random_64_char_string
|
||||||
|
|
||||||
# ---------- Stripe (Phase 1) ----------
|
# Stripe
|
||||||
# Test keys from Stripe Dashboard: https://dashboard.stripe.com/test/apikeys
|
|
||||||
STRIPE_SECRET_KEY=sk_test_...
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
# Webhook secret from stripe CLI: stripe listen --print-secret
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
|
|
||||||
STRIPE_PRICE_ID=price_...
|
STRIPE_PRICE_ID=price_...
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,6 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
||||||
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
||||||
- Never commit directly to `master` or `develop`.
|
- Never commit directly to `master` or `develop`.
|
||||||
- Merge strategy: fast-forward or merge — either is fine.
|
- Merge strategy: fast-forward or merge — either is fine.
|
||||||
- Commit messages must be thorough: describe what changed, why, and
|
|
||||||
list concrete changes as bullet points. Never write single-line
|
|
||||||
"feat: add X" messages.
|
|
||||||
|
|
||||||
### Frontend (Vue.js 3)
|
### Frontend (Vue.js 3)
|
||||||
- `<script setup>` with Composition API only. Never Options API.
|
- `<script setup>` with Composition API only. Never Options API.
|
||||||
|
|
|
||||||
|
|
@ -293,10 +293,6 @@ public class GlobalExceptionHandler {
|
||||||
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
||||||
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
||||||
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
||||||
- All database interaction in tests must go through JPA repositories
|
|
||||||
or EntityManager. Never use JdbcTemplate, DataSource queries, or
|
|
||||||
raw SQL in test code. Tests interact with the database the same way
|
|
||||||
production code does: through the ORM.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,10 @@ dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,15 @@
|
||||||
package se.bilhalsning.config;
|
package se.bilhalsning.config;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
|
||||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
|
||||||
import se.bilhalsning.security.JwtService;
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
public JwtService jwtService(@Value("${app.jwt.secret}") String secret) {
|
|
||||||
return new JwtService(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
|
||||||
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
|
||||||
http
|
|
||||||
.csrf(csrf -> csrf.disable())
|
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
|
||||||
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
|
||||||
.requestMatchers("/api/webhooks/**").permitAll()
|
|
||||||
.requestMatchers("/api/vehicles/**").permitAll()
|
|
||||||
.requestMatchers("/api/templates").permitAll()
|
|
||||||
.anyRequest().authenticated())
|
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
|
||||||
|
|
||||||
return http.build();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package se.bilhalsning.entity;
|
|
||||||
|
|
||||||
import jakarta.persistence.AttributeConverter;
|
|
||||||
import jakarta.persistence.Converter;
|
|
||||||
|
|
||||||
@Converter(autoApply = true)
|
|
||||||
public class SubscriptionConverter implements AttributeConverter<Subscription, String> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String convertToDatabaseColumn(Subscription subscription) {
|
|
||||||
return subscription != null ? subscription.getValue() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Subscription convertToEntityAttribute(String dbData) {
|
|
||||||
if (dbData == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (Subscription subscription : Subscription.values()) {
|
|
||||||
if (subscription.getValue().equals(dbData)) {
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Unknown subscription value: " + dbData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,8 @@ package se.bilhalsning.entity;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Convert;
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.PrePersist;
|
import jakarta.persistence.PrePersist;
|
||||||
import jakarta.persistence.PreUpdate;
|
import jakarta.persistence.PreUpdate;
|
||||||
|
|
@ -24,7 +25,7 @@ public class User {
|
||||||
@Column(name = "password_hash", nullable = false, length = 255)
|
@Column(name = "password_hash", nullable = false, length = 255)
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
|
|
||||||
@Convert(converter = SubscriptionConverter.class)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "subscription", nullable = false, length = 20)
|
@Column(name = "subscription", nullable = false, length = 20)
|
||||||
private Subscription subscription = Subscription.NONE;
|
private Subscription subscription = Subscription.NONE;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
|
||||||
import jakarta.servlet.ServletException;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.security.core.userdetails.User;
|
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|
||||||
|
|
||||||
private final JwtService jwtService;
|
|
||||||
private final UserDetailsServiceImpl userDetailsService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
|
||||||
String authHeader = request.getHeader("Authorization");
|
|
||||||
|
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String token = authHeader.substring(7);
|
|
||||||
String username = jwtService.extractUsername(token);
|
|
||||||
|
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
|
||||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
|
||||||
|
|
||||||
if (jwtService.isTokenValid(token)) {
|
|
||||||
var authToken = new UsernamePasswordAuthenticationToken(
|
|
||||||
userDetails, null, userDetails.getAuthorities());
|
|
||||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import io.jsonwebtoken.ExpiredJwtException;
|
|
||||||
import io.jsonwebtoken.Jwts;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Date;
|
|
||||||
import javax.crypto.SecretKey;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
public class JwtService {
|
|
||||||
|
|
||||||
private static final long DEFAULT_EXPIRATION_MS = 86_400_000;
|
|
||||||
|
|
||||||
private final SecretKey secretKey;
|
|
||||||
private final long expirationMs;
|
|
||||||
|
|
||||||
public JwtService(String secret) {
|
|
||||||
this(secret, DEFAULT_EXPIRATION_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
JwtService(String secret, long expirationMs) {
|
|
||||||
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
|
||||||
this.expirationMs = expirationMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String generateToken(String email) {
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(email)
|
|
||||||
.issuedAt(new Date())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
|
||||||
.signWith(secretKey)
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String extractUsername(String token) {
|
|
||||||
return Jwts.parser()
|
|
||||||
.verifyWith(secretKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.getSubject();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isTokenValid(String token) {
|
|
||||||
try {
|
|
||||||
Jwts.parser()
|
|
||||||
.verifyWith(secretKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token);
|
|
||||||
return true;
|
|
||||||
} catch (ExpiredJwtException e) {
|
|
||||||
return false;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.security.core.userdetails.User;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
|
||||||
String normalizedEmail = email.toLowerCase().trim();
|
|
||||||
return userRepository.findByEmail(normalizedEmail)
|
|
||||||
.map(user -> new User(user.getEmail(), user.getPasswordHash(), List.of()))
|
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + normalizedEmail));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,3 @@ spring:
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
app:
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET}
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,3 @@ spring:
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
|
||||||
app:
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET:dev-secret-change-in-production}
|
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package se.bilhalsning;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import se.bilhalsning.entity.Subscription;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@Transactional
|
|
||||||
class FlywayMigrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldPersistAndRetrieveUser() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("test@example.com");
|
|
||||||
user.setPasswordHash("hash");
|
|
||||||
user.setSubscription(Subscription.BASIC);
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
|
|
||||||
User found = userRepository.findById(user.getId()).orElseThrow();
|
|
||||||
|
|
||||||
assertEquals(user.getId(), found.getId());
|
|
||||||
assertEquals("test@example.com", found.getEmail());
|
|
||||||
assertEquals("hash", found.getPasswordHash());
|
|
||||||
assertEquals(Subscription.BASIC, found.getSubscription());
|
|
||||||
assertNotNull(found.getCreatedAt());
|
|
||||||
assertNotNull(found.getUpdatedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldEnforceUniqueEmail() {
|
|
||||||
User first = new User();
|
|
||||||
first.setEmail("unique@example.com");
|
|
||||||
first.setPasswordHash("hash");
|
|
||||||
userRepository.saveAndFlush(first);
|
|
||||||
|
|
||||||
User duplicate = new User();
|
|
||||||
duplicate.setEmail("unique@example.com");
|
|
||||||
duplicate.setPasswordHash("hash");
|
|
||||||
|
|
||||||
assertThrows(DataIntegrityViolationException.class, () -> {
|
|
||||||
userRepository.saveAndFlush(duplicate);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldPersistAllSubscriptionValues() {
|
|
||||||
for (Subscription sub : Subscription.values()) {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail(sub.getValue() + "@example.com");
|
|
||||||
user.setPasswordHash("hash");
|
|
||||||
user.setSubscription(sub);
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
|
|
||||||
User found = userRepository.findByEmail(sub.getValue() + "@example.com").orElseThrow();
|
|
||||||
assertEquals(sub, found.getSubscription());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldDefaultSubscriptionToNone() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("default@example.com");
|
|
||||||
user.setPasswordHash("hash");
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
|
|
||||||
User found = userRepository.findByEmail("default@example.com").orElseThrow();
|
|
||||||
assertEquals(Subscription.NONE, found.getSubscription());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package se.bilhalsning.config;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
|
||||||
import se.bilhalsning.security.JwtService;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
class SecurityConfigTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private SecurityFilterChain securityFilterChain;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private JwtService jwtService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldProvideBcryptPasswordEncoder() {
|
|
||||||
assertNotNull(passwordEncoder);
|
|
||||||
assertInstanceOf(BCryptPasswordEncoder.class, passwordEncoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldProvideSecurityFilterChain() {
|
|
||||||
assertNotNull(securityFilterChain);
|
|
||||||
assertDoesNotThrow(() -> securityFilterChain.getFilters());
|
|
||||||
assertNotNull(securityFilterChain.getFilters());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldExposeJwtServiceAsBean() {
|
|
||||||
assertNotNull(jwtService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package se.bilhalsning.repository;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@Transactional
|
|
||||||
class UserRepositoryTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldFindByEmailWhenExists() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("find@example.com");
|
|
||||||
user.setPasswordHash("hash");
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
|
|
||||||
Optional<User> result = userRepository.findByEmail("find@example.com");
|
|
||||||
|
|
||||||
assertTrue(result.isPresent());
|
|
||||||
assertEquals("find@example.com", result.get().getEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnEmptyWhenEmailNotFound() {
|
|
||||||
Optional<User> result = userRepository.findByEmail("no@example.com");
|
|
||||||
|
|
||||||
assertFalse(result.isPresent());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnTrueWhenEmailExists() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("exists@example.com");
|
|
||||||
user.setPasswordHash("hash");
|
|
||||||
userRepository.saveAndFlush(user);
|
|
||||||
|
|
||||||
boolean result = userRepository.existsByEmail("exists@example.com");
|
|
||||||
|
|
||||||
assertTrue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnFalseWhenEmailDoesNotExist() {
|
|
||||||
boolean result = userRepository.existsByEmail("nope@example.com");
|
|
||||||
|
|
||||||
assertFalse(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
|
||||||
import jakarta.servlet.ServletException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.List;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
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.mock.web.MockHttpServletRequest;
|
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.security.core.userdetails.User;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class JwtAuthenticationFilterTest {
|
|
||||||
|
|
||||||
private static final String VALID_TOKEN = "valid.jwt.token";
|
|
||||||
private static final String EMAIL = "test@example.com";
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private JwtService jwtService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserDetailsServiceImpl userDetailsService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private FilterChain filterChain;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private JwtAuthenticationFilter filter;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldSetAuthenticationWhenValidToken() throws ServletException, IOException {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
|
||||||
request.addHeader("Authorization", "Bearer " + VALID_TOKEN);
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
UserDetails userDetails = new User(EMAIL, "password", List.of());
|
|
||||||
when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL);
|
|
||||||
when(jwtService.isTokenValid(VALID_TOKEN)).thenReturn(true);
|
|
||||||
when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(userDetails);
|
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
|
||||||
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
assertNotNull(auth);
|
|
||||||
assertEquals(EMAIL, auth.getName());
|
|
||||||
verify(filterChain).doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotSetAuthenticationWhenNoAuthHeader() throws ServletException, IOException {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
|
||||||
|
|
||||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
|
||||||
verify(filterChain).doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotSetAuthenticationWhenHeaderLacksBearerPrefix() throws ServletException, IOException {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
|
||||||
request.addHeader("Authorization", "Basic dXNlcjpwYXNz");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
|
||||||
|
|
||||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
|
||||||
verify(jwtService, never()).extractUsername(anyString());
|
|
||||||
verify(filterChain).doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotSetAuthenticationWhenTokenInvalid() throws ServletException, IOException {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
|
||||||
request.addHeader("Authorization", "Bearer invalid-token");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
when(jwtService.extractUsername("invalid-token")).thenReturn(EMAIL);
|
|
||||||
when(jwtService.isTokenValid("invalid-token")).thenReturn(false);
|
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
|
||||||
|
|
||||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
|
||||||
verify(filterChain).doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldContinueChainWhenAuthAlreadyPresent() throws ServletException, IOException {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
|
||||||
request.addHeader("Authorization", "Bearer " + VALID_TOKEN);
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (existingAuth == null) {
|
|
||||||
// Pre-populate auth context to simulate existing auth
|
|
||||||
UserDetails userDetails = new User("existing@example.com", "password", List.of());
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
|
||||||
new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
|
|
||||||
userDetails, null, userDetails.getAuthorities()));
|
|
||||||
}
|
|
||||||
|
|
||||||
when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL);
|
|
||||||
|
|
||||||
filter.doFilterInternal(request, response, filterChain);
|
|
||||||
|
|
||||||
// Should have skipped JWT processing, existing auth should remain
|
|
||||||
verify(jwtService, never()).isTokenValid(anyString());
|
|
||||||
verify(filterChain).doFilter(request, response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
class JwtServiceTest {
|
|
||||||
|
|
||||||
private static final String SECRET = "this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
|
|
||||||
private static final String EMAIL = "test@example.com";
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldGenerateValidTokenThatCanBeParsed() {
|
|
||||||
JwtService jwtService = new JwtService(SECRET);
|
|
||||||
|
|
||||||
String token = jwtService.generateToken(EMAIL);
|
|
||||||
|
|
||||||
assertNotNull(token);
|
|
||||||
String extracted = jwtService.extractUsername(token);
|
|
||||||
assertEquals(EMAIL, extracted);
|
|
||||||
assertTrue(jwtService.isTokenValid(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldExtractUsername() {
|
|
||||||
JwtService jwtService = new JwtService(SECRET);
|
|
||||||
|
|
||||||
String token = jwtService.generateToken("another@example.com");
|
|
||||||
String username = jwtService.extractUsername(token);
|
|
||||||
|
|
||||||
assertEquals("another@example.com", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnFalseForExpiredToken() {
|
|
||||||
JwtService jwtService = new JwtService(SECRET, 0);
|
|
||||||
|
|
||||||
String token = jwtService.generateToken(EMAIL);
|
|
||||||
|
|
||||||
assertFalse(jwtService.isTokenValid(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnFalseForMalformedToken() {
|
|
||||||
JwtService jwtService = new JwtService(SECRET);
|
|
||||||
|
|
||||||
assertFalse(jwtService.isTokenValid("not-a-valid-token"));
|
|
||||||
assertFalse(jwtService.isTokenValid(""));
|
|
||||||
assertFalse(jwtService.isTokenValid("header.payload.signature"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnFalseForTokenSignedWithDifferentKey() {
|
|
||||||
JwtService first = new JwtService(SECRET);
|
|
||||||
JwtService second = new JwtService("another-secret-that-is-at-least-32-bytes-long");
|
|
||||||
|
|
||||||
String token = first.generateToken(EMAIL);
|
|
||||||
|
|
||||||
assertFalse(second.isTokenValid(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnFalseForTamperedToken() {
|
|
||||||
JwtService jwtService = new JwtService(SECRET);
|
|
||||||
|
|
||||||
String token = jwtService.generateToken(EMAIL);
|
|
||||||
String tampered = token.substring(0, token.length() - 3) + "xyz";
|
|
||||||
|
|
||||||
assertFalse(jwtService.isTokenValid(tampered));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
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.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class UserDetailsServiceImplTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private UserDetailsServiceImpl userDetailsService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldReturnUserDetailsWhenUserExists() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("test@example.com");
|
|
||||||
user.setPasswordHash("hashed-password");
|
|
||||||
when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
UserDetails result = userDetailsService.loadUserByUsername("test@example.com");
|
|
||||||
|
|
||||||
assertEquals("test@example.com", result.getUsername());
|
|
||||||
assertEquals("hashed-password", result.getPassword());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldThrowUsernameNotFoundExceptionWhenUserMissing() {
|
|
||||||
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThrows(UsernameNotFoundException.class, () ->
|
|
||||||
userDetailsService.loadUserByUsername("missing@example.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNormalizeEmailToLowercase() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("uppercase@example.com");
|
|
||||||
user.setPasswordHash("hashed-password");
|
|
||||||
when(userRepository.findByEmail("uppercase@example.com")).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
userDetailsService.loadUserByUsername("UPPERCASE@EXAMPLE.COM");
|
|
||||||
|
|
||||||
verify(userRepository).findByEmail("uppercase@example.com");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
package se.bilhalsning.service;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
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.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import se.bilhalsning.entity.Subscription;
|
|
||||||
import se.bilhalsning.entity.User;
|
|
||||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
|
||||||
import se.bilhalsning.repository.UserRepository;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class UserServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
private UserService userService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldCreateUserWhenEmailIsNew() {
|
|
||||||
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
|
|
||||||
when(passwordEncoder.encode("password123")).thenReturn("hashed");
|
|
||||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
User result = userService.createUser("new@example.com", "password123");
|
|
||||||
|
|
||||||
assertNotNull(result);
|
|
||||||
assertEquals("new@example.com", result.getEmail());
|
|
||||||
assertEquals("hashed", result.getPasswordHash());
|
|
||||||
assertEquals(Subscription.NONE, result.getSubscription());
|
|
||||||
verify(userRepository).save(any(User.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldThrowWhenEmailAlreadyExists() {
|
|
||||||
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
|
|
||||||
|
|
||||||
assertThrows(EmailAlreadyExistsException.class, () ->
|
|
||||||
userService.createUser("taken@example.com", "password123"));
|
|
||||||
|
|
||||||
verify(userRepository, never()).save(any(User.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNormalizeEmailToLowercase() {
|
|
||||||
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
|
|
||||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
|
||||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
User result = userService.createUser("User@Example.COM", "password123");
|
|
||||||
|
|
||||||
assertEquals("user@example.com", result.getEmail());
|
|
||||||
verify(userRepository).existsByEmail("user@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldTrimWhitespaceFromEmail() {
|
|
||||||
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
|
|
||||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
|
||||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
User result = userService.createUser(" user@example.com ", "password123");
|
|
||||||
|
|
||||||
assertEquals("user@example.com", result.getEmail());
|
|
||||||
verify(userRepository).existsByEmail("user@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldHashPasswordBeforeSave() {
|
|
||||||
when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
|
|
||||||
when(passwordEncoder.encode("myPassword")).thenReturn("bcryptHash");
|
|
||||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
userService.createUser("test@example.com", "myPassword");
|
|
||||||
|
|
||||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
|
||||||
verify(userRepository).save(captor.capture());
|
|
||||||
assertEquals("bcryptHash", captor.getValue().getPasswordHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldFindByEmailWhenExists() {
|
|
||||||
User user = new User();
|
|
||||||
user.setEmail("found@example.com");
|
|
||||||
when(userRepository.findByEmail("found@example.com")).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
Optional<User> result = userService.findByEmail("found@example.com");
|
|
||||||
|
|
||||||
assertTrue(result.isPresent());
|
|
||||||
assertEquals("found@example.com", result.get().getEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldFindByEmailNormalizesInput() {
|
|
||||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
userService.findByEmail(" User@Example.COM ");
|
|
||||||
|
|
||||||
verify(userRepository).findByEmail("user@example.com");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,8 @@ services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: bilhej-postgres-prod
|
container_name: bilhej-postgres-prod
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
|
@ -20,6 +22,8 @@ services:
|
||||||
dockerfile: docker/backend.prod.Dockerfile
|
dockerfile: docker/backend.prod.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
container_name: bilhej-backend-prod
|
container_name: bilhej-backend-prod
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: docker
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
|
@ -41,19 +45,13 @@ services:
|
||||||
container_name: bilhej-frontend-prod
|
container_name: bilhej-frontend-prod
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
- "443:443"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
- certs:/etc/nginx/certs
|
- certs:/etc/nginx/certs
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- web
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata-prod:
|
pgdata-prod:
|
||||||
certs:
|
certs:
|
||||||
|
|
||||||
networks:
|
|
||||||
web:
|
|
||||||
external: true
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import App from '@/App.vue'
|
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders RouterView with HomePage content', async () => {
|
|
||||||
router.push('/')
|
|
||||||
await router.isReady()
|
|
||||||
const wrapper = mount(App, {
|
|
||||||
global: {
|
|
||||||
plugins: [router],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('BilHälsning')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import PlateInput from '@/components/PlateInput.vue'
|
|
||||||
|
|
||||||
function createWrapper(modelValue = '') {
|
|
||||||
return mount(PlateInput, {
|
|
||||||
props: { modelValue },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PlateInput', () => {
|
|
||||||
it('renders an input element', () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
expect(wrapper.find('input').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-uppercases lowercase input', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('abc123')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips non-alphanumeric characters', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC 123')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips dashes and symbols', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC-12D')
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC12D'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error message for invalid format after input', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC12')
|
|
||||||
expect(wrapper.text()).toContain('giltigt registreringsnummer')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not show error when input is empty', () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
expect(wrapper.text()).not.toContain('giltigt registreringsnummer')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits lookup on valid ABC123 format', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC123')
|
|
||||||
expect(wrapper.emitted('lookup')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('lookup')?.[0]).toEqual(['ABC123'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits lookup on valid ABC12D format', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC12D')
|
|
||||||
expect(wrapper.emitted('lookup')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('lookup')?.[0]).toEqual(['ABC12D'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not emit lookup on invalid input', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC1')
|
|
||||||
expect(wrapper.emitted('lookup')).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates modelValue via v-model', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC123')
|
|
||||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['ABC123'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prevents re-emission for the same plate', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC123')
|
|
||||||
expect(wrapper.emitted('lookup')).toHaveLength(1)
|
|
||||||
await input.setValue('ABC12')
|
|
||||||
expect(wrapper.emitted('lookup')).toHaveLength(1)
|
|
||||||
await input.setValue('ABC123')
|
|
||||||
expect(wrapper.emitted('lookup')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits again for a different valid plate', async () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
const input = wrapper.find('input')
|
|
||||||
await input.setValue('ABC123')
|
|
||||||
expect(wrapper.emitted('lookup')).toHaveLength(1)
|
|
||||||
await input.setValue('')
|
|
||||||
await input.setValue('DEF456')
|
|
||||||
expect(wrapper.emitted('lookup')).toHaveLength(2)
|
|
||||||
expect(wrapper.emitted('lookup')?.[1]).toEqual(['DEF456'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
describe('Router', () => {
|
|
||||||
it('resolves / to HomePage', async () => {
|
|
||||||
await router.push('/')
|
|
||||||
await router.isReady()
|
|
||||||
expect(router.currentRoute.value.name).toBe('home')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not crash on unknown route', async () => {
|
|
||||||
await router.push('/nonexistent')
|
|
||||||
await router.isReady()
|
|
||||||
expect(router.currentRoute.value.matched.length).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
|
|
||||||
describe('Pinia', () => {
|
|
||||||
it('creates a Pinia instance', () => {
|
|
||||||
const pinia = createPinia()
|
|
||||||
expect(pinia).toBeDefined()
|
|
||||||
expect(pinia.state).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import VehicleInfo from '@/components/VehicleInfo.vue'
|
|
||||||
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
|
|
||||||
|
|
||||||
const mockVehicle: VehicleData = {
|
|
||||||
make: 'Volvo',
|
|
||||||
model: 'V70',
|
|
||||||
year: 2009,
|
|
||||||
color: 'Silver',
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWrapper(props: Record<string, unknown> = {}) {
|
|
||||||
return mount(VehicleInfo, {
|
|
||||||
props: {
|
|
||||||
vehicle: null,
|
|
||||||
loading: false,
|
|
||||||
notFound: false,
|
|
||||||
plate: '',
|
|
||||||
...props,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('VehicleInfo', () => {
|
|
||||||
it('shows loading text when loading is true', () => {
|
|
||||||
const wrapper = createWrapper({ loading: true })
|
|
||||||
expect(wrapper.text()).toContain('Söker...')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows vehicle card with make, model, year, and color', () => {
|
|
||||||
const wrapper = createWrapper({ vehicle: mockVehicle })
|
|
||||||
expect(wrapper.text()).toContain('Volvo')
|
|
||||||
expect(wrapper.text()).toContain('V70')
|
|
||||||
expect(wrapper.text()).toContain('2009')
|
|
||||||
expect(wrapper.text()).toContain('Silver')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows not-found message when notFound is true', () => {
|
|
||||||
const wrapper = createWrapper({ notFound: true, plate: 'ABC123' })
|
|
||||||
expect(wrapper.text()).toContain('Inget fordon hittades')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders nothing in initial state', () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
expect(wrapper.find('.vehicle-info').exists()).toBe(true)
|
|
||||||
expect(wrapper.text().replace(/\s/g, '')).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prioritizes loading over notFound', () => {
|
|
||||||
const wrapper = createWrapper({
|
|
||||||
loading: true,
|
|
||||||
notFound: true,
|
|
||||||
plate: 'ABC123',
|
|
||||||
})
|
|
||||||
expect(wrapper.text()).toContain('Söker...')
|
|
||||||
expect(wrapper.text()).not.toContain('Inget fordon hittades')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prioritizes vehicle over loading', () => {
|
|
||||||
const wrapper = createWrapper({ vehicle: mockVehicle, loading: true })
|
|
||||||
expect(wrapper.text()).toContain('Volvo')
|
|
||||||
expect(wrapper.text()).not.toContain('Söker...')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render vehicle card when vehicle is null and not found', () => {
|
|
||||||
const wrapper = createWrapper({
|
|
||||||
vehicle: null,
|
|
||||||
notFound: false,
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
expect(wrapper.text().replace(/\s/g, '')).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
|
|
||||||
const plate = defineModel<string>({ required: true })
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'lookup', plate: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const touched = ref(false)
|
|
||||||
const lastEmitted = ref('')
|
|
||||||
|
|
||||||
const SWEDISH_PLATE_REGEX = /^[A-Z]{3}(\d{3}|\d{2}[A-Z])$/
|
|
||||||
|
|
||||||
const isValid = computed(() => SWEDISH_PLATE_REGEX.test(plate.value ?? ''))
|
|
||||||
|
|
||||||
const showError = computed(
|
|
||||||
() => touched.value && !isValid.value && (plate.value?.length ?? 0) > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
const rawValue = target.value
|
|
||||||
const transformed = rawValue.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
|
||||||
plate.value = transformed
|
|
||||||
touched.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isValid, (valid) => {
|
|
||||||
if (valid && plate.value && plate.value !== lastEmitted.value) {
|
|
||||||
lastEmitted.value = plate.value
|
|
||||||
emit('lookup', plate.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="plate-input">
|
|
||||||
<label for="plate" class="plate-input__label">Registreringsnummer</label>
|
|
||||||
<input
|
|
||||||
id="plate"
|
|
||||||
type="text"
|
|
||||||
inputmode="text"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
:value="plate"
|
|
||||||
class="plate-input__field"
|
|
||||||
:class="{ 'plate-input__field--error': showError }"
|
|
||||||
placeholder="ABC 123"
|
|
||||||
maxlength="7"
|
|
||||||
@input="handleInput"
|
|
||||||
/>
|
|
||||||
<p v-if="showError" class="plate-input__error">
|
|
||||||
Ange ett giltigt registreringsnummer
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.plate-input {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__field {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-family: monospace;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border: 2px solid #cbd5e0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__field:focus {
|
|
||||||
border-color: #4299e1;
|
|
||||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__field--error {
|
|
||||||
border-color: #e53e3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__field--error:focus {
|
|
||||||
border-color: #e53e3e;
|
|
||||||
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plate-input__error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: #e53e3e;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
export interface VehicleInfo {
|
|
||||||
make: string
|
|
||||||
model: string
|
|
||||||
year: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
vehicle: VehicleInfo | null
|
|
||||||
loading: boolean
|
|
||||||
notFound: boolean
|
|
||||||
plate: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="vehicle-info">
|
|
||||||
<div v-if="vehicle" class="vehicle-info__card">
|
|
||||||
<p class="vehicle-info__card-text">
|
|
||||||
{{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) —
|
|
||||||
{{ vehicle.color }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="loading" class="vehicle-info__loading">Söker...</div>
|
|
||||||
<div v-else-if="notFound" class="vehicle-info__not-found">
|
|
||||||
<p>Inget fordon hittades</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.vehicle-info {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-info__loading {
|
|
||||||
color: #718096;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-info__card {
|
|
||||||
padding: 1rem;
|
|
||||||
background: #f0fff4;
|
|
||||||
border: 1px solid #c6f6d5;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-info__card-text {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-info__not-found {
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fffaf0;
|
|
||||||
border: 1px solid #feebc8;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vehicle-info__not-found p {
|
|
||||||
margin: 0;
|
|
||||||
color: #c05621;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,64 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
import { ref } from 'vue'
|
|
||||||
import PlateInput from '@/components/PlateInput.vue'
|
|
||||||
import VehicleInfo from '@/components/VehicleInfo.vue'
|
|
||||||
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
|
|
||||||
|
|
||||||
const FAKE_VEHICLES: Record<string, VehicleData> = {
|
|
||||||
ABC123: { make: 'Volvo', model: 'V70', year: 2009, color: 'Silver' },
|
|
||||||
ABC12D: { make: 'Volkswagen', model: 'Golf', year: 2020, color: 'Blå' },
|
|
||||||
XYZ789: { make: 'Saab', model: '9-3', year: 2005, color: 'Röd' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const plate = ref('')
|
|
||||||
const vehicle = ref<VehicleData | null>(null)
|
|
||||||
const notFound = ref(false)
|
|
||||||
const lookingUp = ref(false)
|
|
||||||
|
|
||||||
function handleLookup(lookedUpPlate: string) {
|
|
||||||
lookingUp.value = true
|
|
||||||
notFound.value = false
|
|
||||||
vehicle.value = null
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const found = FAKE_VEHICLES[lookedUpPlate]
|
|
||||||
if (found) {
|
|
||||||
vehicle.value = found
|
|
||||||
notFound.value = false
|
|
||||||
} else {
|
|
||||||
vehicle.value = null
|
|
||||||
notFound.value = true
|
|
||||||
}
|
|
||||||
lookingUp.value = false
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div>
|
||||||
<h1>BilHälsning</h1>
|
<h1>BilHälsning</h1>
|
||||||
<p class="home__subtitle">Skicka ett brev till en fordonsägare</p>
|
|
||||||
|
|
||||||
<PlateInput v-model="plate" @lookup="handleLookup" />
|
|
||||||
|
|
||||||
<VehicleInfo
|
|
||||||
:vehicle="vehicle"
|
|
||||||
:loading="lookingUp"
|
|
||||||
:not-found="notFound"
|
|
||||||
:plate="plate"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
.home {
|
|
||||||
max-width: 28rem;
|
|
||||||
margin: 3rem auto 0;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home__subtitle {
|
|
||||||
color: #718096;
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
0
frontend/src/stores/.gitkeep
Normal file
0
frontend/src/stores/.gitkeep
Normal file
Loading…
Reference in a new issue