Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# 로컬 전용 설정은 이 파일을 .env.local로 복사한 뒤 입력합니다.
# .env.local은 Git에서 제외되며 scripts/run-dev.*가 자동으로 읽습니다.
# 기본값은 local(H2)입니다. PostgreSQL은 dev 또는 prod로 바꿉니다.
SPRING_PROFILES_ACTIVE=local

Expand Down Expand Up @@ -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에 커밋하지 않습니다.
Expand Down
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/gradlew text eol=lf
*.sh text eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
*.jar binary
37 changes: 34 additions & 3 deletions docs/development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -163,12 +165,41 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값'
```

같은 설정으로 재실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장이나
역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 최초 계정을 확인한 후에는
역할로 존재하면 덮어쓰지 않고 시작을 중단합니다. 이메일은 모두
`demo.*@example.com` 형식의 가상 주소이며 실제 개인정보를 포함하지 않습니다.
Comment thread
krestar marked this conversation as resolved.
최초 계정을 확인한 후에는
`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
```

## 개발 기반

| 구성 | 역할 | 구현 위치 |
Expand Down
66 changes: 66 additions & 0 deletions scripts/run-dev.ps1
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions scripts/run-dev.sh
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DemoUser> DEMO_USERS = List.of(
demoUser("90000000-0000-0000-0000-000000000003", "데모 운영관리자", "demo.ops@example.com", UserRole.ADMIN),
Comment thread
krestar marked this conversation as resolved.
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;
Expand Down Expand Up @@ -51,46 +74,20 @@ class DemoAuthSeedRunner implements ApplicationRunner {
@Transactional
public void run(ApplicationArguments arguments) {
validateConfiguration();
String normalizedEmail = UserAccount.normalizeEmail(properties.adminEmail());
Optional<UserAccount> 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<Company> 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
);
}

Expand All @@ -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<Company> 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<UserAccount> 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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading