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:
parent
4385f43b08
commit
75911dfffa
13 changed files with 169 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')] : []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -12,4 +12,6 @@ public interface UserRepository extends JpaRepository<User, UUID> {
|
|||
Optional<User> findByEmail(String email);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsByRole(String role);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ spring:
|
|||
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
locations: classpath:db/migration,classpath:db/dev-migration
|
||||
|
||||
app:
|
||||
payment:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -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',
|
||||
|
|
@ -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'),
|
||||
|
|
@ -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[] {})));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue