diff --git a/.env.example b/.env.example index e116584..7bcbf52 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,8 @@ STRIPE_PRICE_ID=price_... # ---------- Swish (Phase 0) ---------- SWISH_NUMBER=0701234567 +# ---------- Production admin (prod profile only) ---------- +# Strong password; never use test1234. Dev seeds use test@bilhej.se instead. +ADMIN_EMAIL=admin@bilhej.se +ADMIN_PASSWORD=change_me_to_a_strong_password + diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 0d2f8a1..3015ab4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -38,6 +38,8 @@ jobs: STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }} STRIPE_PRICE_ID=${{ secrets.STRIPE_PRICE_ID }} SWISH_NUMBER=${{ secrets.SWISH_NUMBER }} + ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }} + ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} EOF - name: Build and start production stack diff --git a/backend/build.gradle b/backend/build.gradle index d4eacdc..e8b571a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -82,3 +82,11 @@ jacocoTestCoverageVerification { tasks.named('check').configure { dependsOn jacocoTestCoverageVerification } + +tasks.register('hashPassword', JavaExec) { + group = 'utility' + description = 'Print BCrypt hash for a password (strength 12). Usage: -Ppassword=secret' + classpath = sourceSets.test.runtimeClasspath + mainClass = 'se.bilhalsning.tools.BcryptHashCli' + args = project.findProperty('password') ? [project.property('password')] : [] +} diff --git a/backend/src/main/java/se/bilhalsning/config/AdminBootstrap.java b/backend/src/main/java/se/bilhalsning/config/AdminBootstrap.java new file mode 100644 index 0000000..26be23e --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/config/AdminBootstrap.java @@ -0,0 +1,50 @@ +package se.bilhalsning.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import se.bilhalsning.entity.User; +import se.bilhalsning.repository.UserRepository; + +@Component +@Profile("prod") +@RequiredArgsConstructor +@Slf4j +public class AdminBootstrap implements ApplicationRunner { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${app.admin.email:}") + private String adminEmail; + + @Value("${app.admin.password:}") + private String adminPassword; + + @Override + public void run(ApplicationArguments args) { + if (userRepository.existsByRole("admin")) { + log.info("Admin account already present, skipping bootstrap"); + return; + } + + if (!StringUtils.hasText(adminEmail) || !StringUtils.hasText(adminPassword)) { + throw new IllegalStateException( + "Production requires ADMIN_EMAIL and ADMIN_PASSWORD when no admin user exists"); + } + + User admin = new User(); + admin.setEmail(adminEmail.trim()); + admin.setPasswordHash(passwordEncoder.encode(adminPassword)); + admin.setRole("admin"); + userRepository.save(admin); + + log.info("Created production admin account for {}", admin.getEmail()); + } +} diff --git a/backend/src/main/java/se/bilhalsning/repository/UserRepository.java b/backend/src/main/java/se/bilhalsning/repository/UserRepository.java index f4159fd..0f66e5f 100644 --- a/backend/src/main/java/se/bilhalsning/repository/UserRepository.java +++ b/backend/src/main/java/se/bilhalsning/repository/UserRepository.java @@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + + boolean existsByRole(String role); } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 36f992c..95af7c8 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -1,4 +1,7 @@ spring: + flyway: + locations: classpath:db/migration,classpath:db/dev-migration + datasource: url: jdbc:postgresql://postgres:5432/${POSTGRES_DB} driver-class-name: org.postgresql.Driver diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 14305a1..61b6c12 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -22,7 +22,7 @@ spring: flyway: enabled: true - locations: classpath:db/migration + locations: classpath:db/migration,classpath:db/dev-migration app: payment: diff --git a/backend/src/main/resources/db/migration/V2__seed_test_user.sql b/backend/src/main/resources/db/dev-migration/V2__seed_test_user.sql similarity index 67% rename from backend/src/main/resources/db/migration/V2__seed_test_user.sql rename to backend/src/main/resources/db/dev-migration/V2__seed_test_user.sql index 4ee9ce3..fd9b06b 100644 --- a/backend/src/main/resources/db/migration/V2__seed_test_user.sql +++ b/backend/src/main/resources/db/dev-migration/V2__seed_test_user.sql @@ -1,7 +1,8 @@ +-- Dev/CI only: test user for local docker and e2e (password: test1234) INSERT INTO users (id, email, password_hash, subscription) VALUES ( 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', - 'test@bilhalsning.se', + 'test@bilhej.se', '$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG', 'none' ); diff --git a/backend/src/main/resources/db/migration/V4__seed_admin_user.sql b/backend/src/main/resources/db/dev-migration/V4__seed_admin_user.sql similarity index 77% rename from backend/src/main/resources/db/migration/V4__seed_admin_user.sql rename to backend/src/main/resources/db/dev-migration/V4__seed_admin_user.sql index 0da0443..1ba4fce 100644 --- a/backend/src/main/resources/db/migration/V4__seed_admin_user.sql +++ b/backend/src/main/resources/db/dev-migration/V4__seed_admin_user.sql @@ -1,3 +1,4 @@ +-- Dev/CI only: admin for local docker and e2e (password: test1234) INSERT INTO users (id, email, password_hash, subscription, role) VALUES ( 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', diff --git a/backend/src/main/resources/db/migration/V6__seed_test_orders.sql b/backend/src/main/resources/db/dev-migration/V6__seed_test_orders.sql similarity index 91% rename from backend/src/main/resources/db/migration/V6__seed_test_orders.sql rename to backend/src/main/resources/db/dev-migration/V6__seed_test_orders.sql index e270e7b..4c028e6 100644 --- a/backend/src/main/resources/db/migration/V6__seed_test_orders.sql +++ b/backend/src/main/resources/db/dev-migration/V6__seed_test_orders.sql @@ -1,4 +1,4 @@ --- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11) +-- Dev/CI only: sample orders for test@bilhej.se (id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11) INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at) VALUES ('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'), diff --git a/backend/src/test/java/se/bilhalsning/config/AdminBootstrapTest.java b/backend/src/test/java/se/bilhalsning/config/AdminBootstrapTest.java new file mode 100644 index 0000000..8aac247 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/config/AdminBootstrapTest.java @@ -0,0 +1,72 @@ +package se.bilhalsning.config; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +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.boot.DefaultApplicationArguments; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import se.bilhalsning.entity.User; +import se.bilhalsning.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class AdminBootstrapTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AdminBootstrap adminBootstrap; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adminBootstrap, "adminEmail", "admin@bilhej.se"); + ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "secure-production-password"); + } + + @Test + void shouldSkipBootstrapWhenAdminAlreadyExists() { + when(userRepository.existsByRole("admin")).thenReturn(true); + + adminBootstrap.run(new DefaultApplicationArguments(new String[] {})); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void shouldCreateAdminWhenMissing() { + when(userRepository.existsByRole("admin")).thenReturn(false); + when(passwordEncoder.encode("secure-production-password")).thenReturn("encoded-hash"); + + adminBootstrap.run(new DefaultApplicationArguments(new String[] {})); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals("admin@bilhej.se", saved.getEmail()); + org.junit.jupiter.api.Assertions.assertEquals("encoded-hash", saved.getPasswordHash()); + org.junit.jupiter.api.Assertions.assertEquals("admin", saved.getRole()); + } + + @Test + void shouldFailWhenCredentialsMissingAndNoAdmin() { + ReflectionTestUtils.setField(adminBootstrap, "adminPassword", ""); + when(userRepository.existsByRole("admin")).thenReturn(false); + + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> adminBootstrap.run(new DefaultApplicationArguments(new String[] {}))); + } +} diff --git a/backend/src/test/java/se/bilhalsning/tools/BcryptHashCli.java b/backend/src/test/java/se/bilhalsning/tools/BcryptHashCli.java new file mode 100644 index 0000000..6ff5762 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/tools/BcryptHashCli.java @@ -0,0 +1,17 @@ +package se.bilhalsning.tools; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** Run: ./gradlew hashPassword -Ppassword='your-password' */ +public final class BcryptHashCli { + + private BcryptHashCli() {} + + public static void main(String[] args) { + if (args.length != 1) { + System.err.println("Usage: gradlew hashPassword -Ppassword='...'"); + System.exit(1); + } + System.out.println(new BCryptPasswordEncoder(12).encode(args[0])); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9d7abf6..be356ef 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,6 +8,8 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata-prod:/var/lib/postgresql/data + ports: + - "127.0.0.1:5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s @@ -21,7 +23,7 @@ services: context: . container_name: bilhej-backend-prod environment: - SPRING_PROFILES_ACTIVE: docker + SPRING_PROFILES_ACTIVE: docker,prod POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -30,6 +32,8 @@ services: STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} SWISH_NUMBER: ${SWISH_NUMBER} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} depends_on: postgres: condition: service_healthy