Compare commits

...

5 commits

Author SHA1 Message Date
210ac87ede feat: extract VehicleInfo component from HomePage
Move vehicle-info display logic out of HomePage into a reusable
VehicleInfo component. The component accepts vehicle, loading,
notFound, and plate props and renders the correct state with
priority: vehicle card > loading > not found. Follows the
small-page-component pattern from CODING_GUIDELINES.md.

- Create VehicleInfo.vue with 3-state v-if chain and scoped styles
- Define and export VehicleInfo interface (make/model/year/color)
- Add VehicleInfo.spec.ts with 7 tests covering all states and
  priority edge cases
- Update HomePage.vue to use VehicleInfo, replacing 3 inline
  v-if/else-if blocks with a single component tag
- Remove 5 unused CSS classes from HomePage (home__status,
  home__vehicle, home__vehicle-text, home__not-found,
  home__not-found p)
- Update AGENTS.md to require thorough commit messages with bullet
  points
2026-05-01 18:06:04 +02:00
078f07f2ac feat: add PlateInput component with Swedish plate validation and fake vehicle lookup 2026-05-01 17:38:28 +02:00
ce95a451ce feat: implement JWT authentication — service, filter, SecurityFilterChain 2026-05-01 17:38:17 +02:00
0d9baeb6e5 feat: add Subscription enum, converter, entity lifecycle hooks, and ORM-only test rule 2026-05-01 17:38:11 +02:00
c6e2e509eb chore: add JWT secret env config, jjwt deps, and docker-compose prod fixes 2026-05-01 17:38:03 +02:00
29 changed files with 1254 additions and 14 deletions

View file

@ -1,16 +1,25 @@
# BilHej Environment Variables
# 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_USER=bilhej
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
# Stripe
# ---------- Stripe (Phase 1) ----------
# Test keys from Stripe Dashboard: https://dashboard.stripe.com/test/apikeys
STRIPE_SECRET_KEY=sk_test_...
# Webhook secret from stripe CLI: stripe listen --print-secret
STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_...

View file

@ -126,6 +126,9 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
- Never commit directly to `master` or `develop`.
- 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)
- `<script setup>` with Composition API only. Never Options API.

View file

@ -293,6 +293,10 @@ public class GlobalExceptionHandler {
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
- 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.
---

View file

@ -24,10 +24,13 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'

View file

@ -1,15 +1,46 @@
package se.bilhalsning.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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.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
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
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();
}
}

View file

@ -0,0 +1,26 @@
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);
}
}

View file

@ -2,8 +2,7 @@ package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Convert;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
@ -25,7 +24,7 @@ public class User {
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Convert(converter = SubscriptionConverter.class)
@Column(name = "subscription", nullable = false, length = 20)
private Subscription subscription = Subscription.NONE;

View file

@ -0,0 +1,50 @@
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);
}
}

View file

@ -0,0 +1,57 @@
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;
}
}
}

View file

@ -0,0 +1,25 @@
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));
}
}

View file

@ -11,3 +11,7 @@ spring:
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
app:
jwt:
secret: ${JWT_SECRET}

View file

@ -23,3 +23,7 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
app:
jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -0,0 +1,81 @@
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());
}
}

View file

@ -0,0 +1,44 @@
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);
}
}

View file

@ -0,0 +1,59 @@
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);
}
}

View file

@ -0,0 +1,138 @@
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);
}
}

View file

@ -0,0 +1,74 @@
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));
}
}

View file

@ -0,0 +1,61 @@
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");
}
}

View file

@ -0,0 +1,121 @@
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");
}
}

View file

@ -2,8 +2,6 @@ services:
postgres:
image: postgres:16
container_name: bilhej-postgres-prod
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
@ -22,8 +20,6 @@ services:
dockerfile: docker/backend.prod.Dockerfile
context: .
container_name: bilhej-backend-prod
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
POSTGRES_DB: ${POSTGRES_DB}
@ -45,13 +41,19 @@ services:
container_name: bilhej-frontend-prod
ports:
- "3000:80"
- "443:443"
depends_on:
- backend
volumes:
- certs:/etc/nginx/certs
networks:
- default
- web
restart: unless-stopped
volumes:
pgdata-prod:
certs:
networks:
web:
external: true

View file

@ -0,0 +1,17 @@
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')
})
})

View file

@ -0,0 +1,102 @@
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'])
})
})

View file

@ -0,0 +1,16 @@
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)
})
})

View file

@ -0,0 +1,10 @@
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()
})
})

View file

@ -0,0 +1,74 @@
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('')
})
})

View file

@ -0,0 +1,106 @@
<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>

View file

@ -0,0 +1,65 @@
<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 }}) &mdash;
{{ 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>

View file

@ -1,9 +1,64 @@
<script setup lang="ts"></script>
<script setup lang="ts">
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>
<div>
<div class="home">
<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>
</template>
<style scoped></style>
<style scoped>
.home {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.home__subtitle {
color: #718096;
margin: 0 0 1.5rem 0;
}
</style>