From 75911dfffa7f612220acc5d5fbef4e1a63e58f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 21 May 2026 15:14:03 +0200 Subject: [PATCH] Separate dev database seeds from production and bootstrap prod admin. Production must not ship test users, demo orders, or test1234. Dev and CI still need seeded users for e2e. Prod creates one admin from deploy secrets. - Move V2/V4/V6 seed migrations to db/dev-migration - Add application-prod.yml with schema-only Flyway and ignore-missing for moved seeds - Add AdminBootstrap to create admin from ADMIN_EMAIL and ADMIN_PASSWORD - Wire docker,prod profile, deploy secrets, and localhost:5433 for SSH DB access - Add hashPassword Gradle task for optional manual bcrypt generation --- .env.example | 5 ++ .forgejo/workflows/deploy.yml | 2 + backend/build.gradle | 8 +++ .../se/bilhalsning/config/AdminBootstrap.java | 50 +++++++++++++ .../repository/UserRepository.java | 2 + .../src/main/resources/application-docker.yml | 3 + backend/src/main/resources/application.yml | 2 +- .../V2__seed_test_user.sql | 3 +- .../V4__seed_admin_user.sql | 1 + .../V6__seed_test_orders.sql | 2 +- .../config/AdminBootstrapTest.java | 72 +++++++++++++++++++ .../se/bilhalsning/tools/BcryptHashCli.java | 17 +++++ docker-compose.prod.yml | 6 +- 13 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/config/AdminBootstrap.java rename backend/src/main/resources/db/{migration => dev-migration}/V2__seed_test_user.sql (67%) rename backend/src/main/resources/db/{migration => dev-migration}/V4__seed_admin_user.sql (77%) rename backend/src/main/resources/db/{migration => dev-migration}/V6__seed_test_orders.sql (91%) create mode 100644 backend/src/test/java/se/bilhalsning/config/AdminBootstrapTest.java create mode 100644 backend/src/test/java/se/bilhalsning/tools/BcryptHashCli.java 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