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
This commit is contained in:
Joakim Mörling 2026-05-21 15:14:03 +02:00
parent 4385f43b08
commit 75911dfffa
13 changed files with 169 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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')] : []
}

View file

@ -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());
}
}

View file

@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByRole(String role);
}

View file

@ -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

View file

@ -22,7 +22,7 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
locations: classpath:db/migration,classpath:db/dev-migration
app:
payment:

View file

@ -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'
);

View file

@ -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',

View file

@ -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'),

View file

@ -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<User> 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[] {})));
}
}

View file

@ -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]));
}
}

View file

@ -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