diff --git a/.env.example b/.env.example index 8a30db3..5c9f904 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +# 로컬 전용 설정은 이 파일을 .env.local로 복사한 뒤 입력합니다. +# .env.local은 Git에서 제외되며 scripts/run-dev.*가 자동으로 읽습니다. # 기본값은 local(H2)입니다. PostgreSQL은 dev 또는 prod로 바꿉니다. SPRING_PROFILES_ACTIVE=local @@ -45,7 +47,8 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true -# 선택 사항: local(H2)에서 로그인 가능한 초기 ADMIN 계정을 한 번 생성합니다. +# 선택 사항: local(H2)에서 로그인 가능한 user_account 더미 계정 20명을 생성합니다. +# 설정한 ADMIN 1명과 고정된 가상 계정 19명이 같은 로컬 비밀번호를 사용합니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. # PostgreSQL dev/prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. diff --git a/.gitattributes b/.gitattributes index 8af972c..16b0b0d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ /gradlew text eol=lf +*.sh text eol=lf +*.ps1 text eol=crlf *.bat text eol=crlf *.jar binary diff --git a/docs/development-guide.md b/docs/development-guide.md index d69a4b9..3d3a085 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -153,8 +153,10 @@ runtime 계정은 업무 DML, migration 계정은 Flyway 적용만 담당합니 ## 선택 사항: local Demo Seed -local H2에서만 사용할 사업장과 `ADMIN` 계정이 필요할 때 명시적으로 켭니다. -기본값과 비밀번호 기본값은 없습니다. +local H2에서만 사용할 사업장과 `user_account` 더미 계정 20명이 필요할 때 +명시적으로 켭니다. 설정한 관리자 1명과 고정된 가상 계정 19명 +(`ADMIN` 1명, `HR` 12명, `VIEWER` 6명)을 만들며, 모든 계정은 로컬에서 설정한 +같은 비밀번호로 로그인할 수 있습니다. 기본값과 비밀번호 기본값은 없습니다. ```bash export DEMO_SEED_ENABLED=true @@ -163,12 +165,41 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값' ``` 같은 설정으로 재실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장이나 -역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 최초 계정을 확인한 후에는 +역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 이메일은 모두 +`demo.*@example.com` 형식의 가상 주소이며 실제 개인정보를 포함하지 않습니다. +최초 계정을 확인한 후에는 `DEMO_SEED_ENABLED=false`로 돌려놓습니다. PostgreSQL `dev`·`prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 계정을 준비합니다. +### 팀 공통 PostgreSQL Demo 실행 + +저장소의 `.env.example`을 Git에서 제외되는 `.env.local`로 복사하고 로컬 +PostgreSQL 비밀번호, JWT Secret과 Demo 비밀번호를 입력합니다. 실제 Secret은 +Git, Issue 또는 메신저로 공유하지 않습니다. + +```bash +# macOS / Linux +cp .env.example .env.local +./scripts/run-dev.sh +``` + +```powershell +# Windows PowerShell +Copy-Item .env.example .env.local +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +``` + +두 스크립트 모두 PostgreSQL `dev` profile과 Demo Seed를 활성화합니다. `DB_URL`, +migration/runtime username이 비어 있으면 로컬 기본값 +`jdbc:postgresql://localhost:5432/fowoco_test`와 `postgres`를 사용합니다. +대상 데이터베이스는 최초 한 번 직접 생성합니다. + +```bash +createdb -h localhost -p 5432 -U postgres fowoco_test +``` + ## 개발 기반 | 구성 | 역할 | 구현 위치 | diff --git a/scripts/run-dev.ps1 b/scripts/run-dev.ps1 new file mode 100644 index 0000000..15e31bb --- /dev/null +++ b/scripts/run-dev.ps1 @@ -0,0 +1,66 @@ +$ErrorActionPreference = "Stop" + +$serverDirectory = Split-Path -Parent $PSScriptRoot +$environmentFile = Join-Path $serverDirectory ".env.local" + +if (-not (Test-Path -LiteralPath $environmentFile)) { + throw ".env.local is missing. Copy .env.example to .env.local and set local values." +} + +Get-Content -LiteralPath $environmentFile -Encoding UTF8 | ForEach-Object { + $line = $_.Trim() + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("#")) { + return + } + + $parts = $line -split "=", 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0])) { + throw "Invalid environment variable in .env.local: $line" + } + + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if ($value.Length -ge 2) { + $isDoubleQuoted = $value.StartsWith('"') -and $value.EndsWith('"') + $isSingleQuoted = $value.StartsWith("'") -and $value.EndsWith("'") + if ($isDoubleQuoted -or $isSingleQuoted) { + $value = $value.Substring(1, $value.Length - 2) + } + } + [Environment]::SetEnvironmentVariable($name, $value, "Process") +} + +$env:SPRING_PROFILES_ACTIVE = "dev" +if ([string]::IsNullOrWhiteSpace($env:DB_URL)) { + $env:DB_URL = "jdbc:postgresql://localhost:5432/fowoco_test" +} +if ([string]::IsNullOrWhiteSpace($env:DB_MIGRATION_USERNAME)) { + $env:DB_MIGRATION_USERNAME = "postgres" +} +if ([string]::IsNullOrWhiteSpace($env:DB_RUNTIME_USERNAME)) { + $env:DB_RUNTIME_USERNAME = "postgres" +} + +$requiredVariables = @( + "DB_MIGRATION_PASSWORD", + "DB_RUNTIME_PASSWORD", + "JWT_SECRET_BASE64", + "DEMO_SEED_ADMIN_PASSWORD" +) + +foreach ($variableName in $requiredVariables) { + $value = [Environment]::GetEnvironmentVariable($variableName, "Process") + if ([string]::IsNullOrWhiteSpace($value)) { + throw "$variableName is required. Check .env.local." + } +} + +$exitCode = 0 +Push-Location $serverDirectory +try { + & .\gradlew.bat bootRun --args="--app.demo-seed.enabled=true" + $exitCode = $LASTEXITCODE +} finally { + Pop-Location +} +exit $exitCode diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh new file mode 100644 index 0000000..f16ba4f --- /dev/null +++ b/scripts/run-dev.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +server_dir="$(cd "${script_dir}/.." && pwd)" +environment_file="${server_dir}/.env.local" + +if [[ ! -f "${environment_file}" ]]; then + echo ".env.local이 없습니다." >&2 + echo "cp .env.example .env.local 후 로컬 설정값을 입력하세요." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "${environment_file}" +set +a + +export SPRING_PROFILES_ACTIVE=dev +: "${DB_URL:=jdbc:postgresql://localhost:5432/fowoco_test}" +: "${DB_MIGRATION_USERNAME:=postgres}" +: "${DB_RUNTIME_USERNAME:=postgres}" +export DB_URL DB_MIGRATION_USERNAME DB_RUNTIME_USERNAME + +required_variables=( + DB_MIGRATION_PASSWORD + DB_RUNTIME_PASSWORD + JWT_SECRET_BASE64 + DEMO_SEED_ADMIN_PASSWORD +) + +for variable_name in "${required_variables[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + echo "${variable_name} 환경변수가 필요합니다. .env.local을 확인하세요." >&2 + exit 1 + fi +done + +cd "${server_dir}" +exec ./gradlew bootRun --args="--app.demo-seed.enabled=true" diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 02179ec..0fd8e44 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -7,8 +7,10 @@ import com.fowoco.server.company.domain.Company; import java.time.Clock; import java.time.Instant; +import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -20,6 +22,27 @@ class DemoAuthSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoAuthSeedRunner.class); private static final int MINIMUM_DEMO_PASSWORD_LENGTH = 12; + private static final List DEMO_USERS = List.of( + demoUser("90000000-0000-0000-0000-000000000003", "데모 운영관리자", "demo.ops@example.com", UserRole.ADMIN), + demoUser("90000000-0000-0000-0000-000000000004", "김하늘", "demo.hr01@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000005", "이도윤", "demo.hr02@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000006", "박서연", "demo.hr03@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000007", "최민준", "demo.hr04@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000008", "정수빈", "demo.hr05@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000009", "강지훈", "demo.hr06@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000010", "윤가은", "demo.hr07@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000011", "조현우", "demo.hr08@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000012", "임채원", "demo.hr09@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000013", "한준서", "demo.hr10@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000014", "오유진", "demo.hr11@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000015", "서지민", "demo.hr12@example.com", UserRole.HR), + demoUser("90000000-0000-0000-0000-000000000016", "데모 조회자 01", "demo.viewer01@example.com", UserRole.VIEWER), + demoUser("90000000-0000-0000-0000-000000000017", "데모 조회자 02", "demo.viewer02@example.com", UserRole.VIEWER), + demoUser("90000000-0000-0000-0000-000000000018", "데모 조회자 03", "demo.viewer03@example.com", UserRole.VIEWER), + demoUser("90000000-0000-0000-0000-000000000019", "데모 조회자 04", "demo.viewer04@example.com", UserRole.VIEWER), + demoUser("90000000-0000-0000-0000-000000000020", "데모 조회자 05", "demo.viewer05@example.com", UserRole.VIEWER), + demoUser("90000000-0000-0000-0000-000000000021", "데모 조회자 06", "demo.viewer06@example.com", UserRole.VIEWER) + ); private final DemoAuthSeedProperties properties; private final CompanyRepository companyRepository; @@ -51,46 +74,20 @@ class DemoAuthSeedRunner implements ApplicationRunner { @Transactional public void run(ApplicationArguments arguments) { validateConfiguration(); - String normalizedEmail = UserAccount.normalizeEmail(properties.adminEmail()); - Optional existingUser = - userAccountRepository.findByNormalizedEmail(normalizedEmail); - if (existingUser.isPresent()) { - verifyExistingUser(existingUser.orElseThrow()); - verifyExistingCompany(); - LOGGER.info( - "demo_auth_seed already_exists company_id={} user_id={}", - properties.companyId(), - properties.adminUserId() - ); - return; - } - Instant now = clock.instant(); - Optional existingCompany = companyRepository.findById(properties.companyId()); - if (existingCompany.isEmpty()) { - companyRepository.insert(Company.create( - properties.companyId(), - properties.companyName(), - now - )); - } else if (!existingCompany.orElseThrow().isActive()) { - throw new IllegalStateException("the configured demo company is not active"); - } + ensureCompany(now); - UserAccount demoAdmin = UserAccount.create( + seedUser(new DemoUser( properties.adminUserId(), - properties.companyId(), properties.adminDisplayName(), properties.adminEmail(), - passwordEncoder.encode(properties.adminPassword()), - UserRole.ADMIN, - now - ); - userAccountRepository.insert(demoAdmin); + UserRole.ADMIN + ), now); + DEMO_USERS.forEach(user -> seedUser(user, now)); LOGGER.info( - "demo_auth_seed created company_id={} user_id={}", + "demo_auth_seed ready company_id={} user_count={}", properties.companyId(), - properties.adminUserId() + DEMO_USERS.size() + 1 ); } @@ -108,31 +105,77 @@ private void validateConfiguration() { + " characters" ); } + if (DEMO_USERS.stream().anyMatch(user -> user.userId().equals(properties.adminUserId()))) { + throw new IllegalStateException("demo seed adminUserId conflicts with a reserved demo user id"); + } + String normalizedAdminEmail = UserAccount.normalizeEmail(properties.adminEmail()); + if (DEMO_USERS.stream().anyMatch(user -> + UserAccount.normalizeEmail(user.email()).equals(normalizedAdminEmail))) { + throw new IllegalStateException("demo seed adminEmail conflicts with a reserved demo user email"); + } } - private void verifyExistingUser(UserAccount userAccount) { - if (!properties.adminUserId().equals(userAccount.userId()) + private void ensureCompany(Instant now) { + Optional existingCompany = companyRepository.findById(properties.companyId()); + if (existingCompany.isEmpty()) { + companyRepository.insert(Company.create( + properties.companyId(), + properties.companyName(), + now + )); + return; + } + if (!existingCompany.orElseThrow().isActive()) { + throw new IllegalStateException("the configured demo company is not active"); + } + } + + private void seedUser(DemoUser demoUser, Instant now) { + String normalizedEmail = UserAccount.normalizeEmail(demoUser.email()); + Optional existingByEmail = + userAccountRepository.findByNormalizedEmail(normalizedEmail); + if (existingByEmail.isPresent()) { + verifyExistingUser(existingByEmail.orElseThrow(), demoUser); + return; + } + if (userAccountRepository + .findByUserIdAndCompanyId(demoUser.userId(), properties.companyId()) + .isPresent()) { + throw new IllegalStateException("a reserved demo user id already belongs to another email"); + } + userAccountRepository.insert(UserAccount.create( + demoUser.userId(), + properties.companyId(), + demoUser.displayName(), + demoUser.email(), + passwordEncoder.encode(properties.adminPassword()), + demoUser.role(), + now + )); + } + + private void verifyExistingUser(UserAccount userAccount, DemoUser demoUser) { + if (!demoUser.userId().equals(userAccount.userId()) || !properties.companyId().equals(userAccount.companyId()) - || userAccount.role() != UserRole.ADMIN + || userAccount.role() != demoUser.role() || !userAccount.canLogin()) { throw new IllegalStateException( - "the configured demo email already belongs to a different or inactive account" + "a configured demo email already belongs to a different or inactive account" ); } } - private void verifyExistingCompany() { - Company company = companyRepository.findById(properties.companyId()) - .orElseThrow(() -> new IllegalStateException("the configured demo company does not exist")); - if (!company.isActive()) { - throw new IllegalStateException("the configured demo company is not active"); - } - } - private String requireText(String value, String propertyName) { if (value == null || value.isBlank()) { throw new IllegalStateException(propertyName + " must be configured when demo seed is enabled"); } return value.strip(); } + + private static DemoUser demoUser(String userId, String displayName, String email, UserRole role) { + return new DemoUser(UUID.fromString(userId), displayName, email, role); + } + + private record DemoUser(UUID userId, String displayName, String email, UserRole role) { + } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 227bb16..752e104 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -48,5 +48,25 @@ void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { Integer.class, ADMIN_USER_ID )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ?", + Integer.class, + COMPANY_ID + )).isEqualTo(20); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", + Integer.class, + COMPANY_ID + )).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", + Integer.class, + COMPANY_ID + )).isEqualTo(12); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", + Integer.class, + COMPANY_ID + )).isEqualTo(6); } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index 3327a17..b0aa022 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -5,6 +5,7 @@ import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; import com.fowoco.server.company.domain.CompanyStatus; @@ -45,7 +46,7 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception runner.run(new DefaultApplicationArguments(new String[0])); assertThat(companyRepository.companies).hasSize(1); - assertThat(userAccountRepository.users).hasSize(1); + assertThat(userAccountRepository.users).hasSize(20); UserAccount admin = userAccountRepository.users.get(ADMIN_USER_ID); assertThat(admin.companyId()).isEqualTo(COMPANY_ID); assertThat(admin.displayName()).isEqualTo(ADMIN_DISPLAY_NAME); @@ -54,6 +55,17 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception assertThat(passwordEncoder.matches(ADMIN_PASSWORD, admin.passwordHash())).isTrue(); assertThat(admin.role().name()).isEqualTo("ADMIN"); assertThat(admin.canLogin()).isTrue(); + assertThat(userAccountRepository.users.values()) + .filteredOn(user -> user.role() == UserRole.ADMIN) + .hasSize(2); + assertThat(userAccountRepository.users.values()) + .filteredOn(user -> user.role() == UserRole.HR) + .hasSize(12); + assertThat(userAccountRepository.users.values()) + .filteredOn(user -> user.role() == UserRole.VIEWER) + .hasSize(6); + assertThat(userAccountRepository.users.values()) + .allMatch(user -> passwordEncoder.matches(ADMIN_PASSWORD, user.passwordHash())); } @Test