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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package repit.repit_api_server.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;

/**
* 빠지면 서버가 떠도 제대로 일하지 못하는 설정을 기동할 때 확인한다. 어기면 서버가 뜨지 않는다.
*
* <p>콜백 주소가 틀리면 분석·채팅 서버의 결과가 엉뚱한 곳으로 가거나 아예 오지 않는다. 그런데 요청은 202로 접수돼
* 겉으로는 정상처럼 보이고, 실패는 몇 분 뒤 시간 초과로만 드러난다. 블루/그린 배포라 새 서버가 뜨지 않으면 이전 서버가
* 그대로 남으므로, 잘못된 설정은 배포 시점에 막는 편이 낫다. 연결까지 시험하지는 않는다 — 외부 서버가 잠깐 멈췄다고
* 기동을 막으면 안 된다.
*/
@Component
class RequiredSettingsCheck {

// 이 프로필이 켜져 있으면 운영으로 본다. 개발용 주소(ngrok, localhost)를 콜백 주소로 받지 않는다.
static final String PRODUCTION_PROFILE = "prod";

RequiredSettingsCheck(@Value("${app.callback-base-url:}") String callbackBaseUrl,
@Value("${app.internal-auth.required:false}") boolean internalAuthRequired,
@Value("${app.internal-auth.token:}") String internalAuthToken,
Environment environment) {
verifyCallbackBaseUrl(callbackBaseUrl, environment.matchesProfiles(PRODUCTION_PROFILE));
verifyInternalAuth(internalAuthRequired, internalAuthToken);
}

/**
* 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 뒤에 콜백 경로를 바로 붙이므로 쿼리·조각·끝 슬래시가 없어야 한다.
* 끝 슬래시가 있으면 "//api/..."가 되어 시큐리티 방화벽이 콜백을 모두 400으로 막는다.
*/
static void verifyCallbackBaseUrl(String value, boolean production) {
if (value == null || value.isBlank()) {
throw new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 비어 있습니다. "
+ "분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소를 넣어 주세요. 예: https://api.repit.example");
}
URI uri;
try {
uri = new URI(value.trim());
} catch (URISyntaxException e) {
throw invalid(null, "주소 형식이 아닙니다");
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!scheme.equals("http") && !scheme.equals("https")) {
throw invalid(uri.getHost(), "http 또는 https 주소여야 합니다");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || value.trim().endsWith("/")) {
throw invalid(uri.getHost(), "호스트가 있고 사용자 정보·쿼리·조각·끝 슬래시가 없어야 합니다");
}
if (!production) {
return;
}
String host = uri.getHost().toLowerCase(Locale.ROOT);
if (!scheme.equals("https")) {
throw invalid(host, "운영에서는 https 주소여야 합니다");
}
// 분석 서버는 기본 포트가 아닌 콜백 주소를 422로 거부한다(CallbackUrls.require와 같은 제한).
if (uri.getPort() != -1 && uri.getPort() != 443) {
throw invalid(host, "운영에서는 포트를 생략하거나 443이어야 합니다");
}
if (host.contains("ngrok") || isLoopback(host)) {
throw invalid(host, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다");
}
}

/**
* 내부 인증을 필수로 켰는데 인증값이 비어 있으면 막는다. 비어 있으면 콜백을 인증 없이 받는 지금 동작으로 조용히
* 돌아가, 켰다고 믿는 동안 아무나 콜백을 보낼 수 있다. 인증값은 오류 메시지에 싣지 않는다.
*/
static void verifyInternalAuth(boolean required, String token) {
if (required && (token == null || token.isBlank())) {
throw new IllegalStateException("INTERNAL_AUTH_REQUIRED가 true인데 INTERNAL_AUTH_TOKEN(app.internal-auth.token)이 "
+ "비어 있습니다. 분석·채팅 서버와 같은 값을 넣거나, 아직 헤더를 보내지 않는다면 INTERNAL_AUTH_REQUIRED를 끄세요.");
}
}

private static boolean isLoopback(String host) {
return host.equals("localhost") || host.endsWith(".localhost") || host.startsWith("127.")
|| host.equals("[::1]") || host.equals("0.0.0.0");
}

/**
* 주소 원문은 싣지 않는다. 막는 이유가 사용자 정보·쿼리이기도 해서, 원문을 남기면 그 안의 자격 증명이 기동 실패 로그에
* 그대로 남는다. 어느 설정인지 짚을 수 있도록 검증 항목과 호스트만 싣는다.
*/
private static IllegalStateException invalid(String host, String reason) {
return new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 올바르지 않습니다 — " + reason
+ ". 호스트: " + (host == null ? "(읽을 수 없음)" : host));
}
}
8 changes: 7 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ app:
# 비어 있으면 콜백을 인증 없이 받는다. 부르는 쪽이 헤더를 보내도록 배포한 뒤에 채워 강제한다.
# 영상 분석 콜백은 예외다 -- 비어 있으면 401로 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED).
token: ${INTERNAL_AUTH_TOKEN:}
callback-base-url: ${CALLBACK_BASE_URL:https://wildcat-startle-rope.ngrok-free.dev}
# true면 token이 비어 있을 때 서버가 뜨지 않는다. 비어 있는 채로 인증 없이 받는 상태로 조용히 돌아가는 것을 막는다.
# 분석·채팅 서버가 헤더를 보내도록 배포를 마친 뒤 마지막에 켠다.
required: ${INTERNAL_AUTH_REQUIRED:false}
# 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 기본값이 없다 -- 비었거나 형식이 틀리면 서버가 뜨지 않는다.
# 끝에 슬래시를 붙이지 않는다. prod 프로필에서는 https만 받고 ngrok·localhost 같은 개발용 주소를 받지 않는다.
# 로컬 개발은 .env에 넣는다(예: ngrok 주소). 테스트는 src/test/resources/config/application.yaml이 채운다.
callback-base-url: ${CALLBACK_BASE_URL:}
feedback:
# 이 시간을 넘도록 분석 서버 콜백이 오지 않으면 실패로 처리하고 재요청을 허용한다.
pending-timeout: 10m
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package repit.repit_api_server.global.config;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/** 빠지면 서버가 제대로 일하지 못하는 설정을 기동 시점에 막는지 본다(N-6). */
class RequiredSettingsCheckTest {

private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(RequiredSettingsCheck.class);

@Test
void 콜백_주소가_없거나_비면_기동하지_않는다() {
runner.run(context -> assertThat(context).hasFailed()
.getFailure().rootCause().hasMessageContaining("CALLBACK_BASE_URL"));
runner.withPropertyValues("app.callback-base-url= ")
.run(context -> assertThat(context).hasFailed());
}

@Test
void 형식이_틀린_콜백_주소는_받지_않는다() {
for (String url : List.of("api.repit.example", "ftp://api.repit.example", "https://",
"https://user:pw@api.repit.example", "https://api.repit.example?env=dev",
"https://api.repit.example#top", "https://api.repit.example/", "https://api repit.example")) {
assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false))
.as(url)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("CALLBACK_BASE_URL");
}
}

/** 로컬은 ngrok이나 localhost로 개발한다. 운영 프로필이 아닐 때는 받는다. */
@Test
void 운영이_아니면_개발용_주소를_받는다() {
for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "http://localhost:8080",
"http://127.0.0.1:8080", "https://api.repit.example/base")) {
assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false)).as(url)
.doesNotThrowAnyException();
}
}

@Test
void 운영에서는_https_기본_포트가_아니거나_개발용_주소면_받지_않는다() {
for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "https://abc.ngrok.io",
"https://localhost", "https://api.localhost", "https://127.0.0.1", "https://[::1]",
"http://api.repit.example", "https://api.repit.example:8443")) {
assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url)
.isInstanceOf(IllegalStateException.class);
}
// 분석 서버는 포트를 생략했거나 443인 주소만 받는다.
for (String url : List.of("https://api.repit.example", "https://api.repit.example:443")) {
assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url)
.doesNotThrowAnyException();
}
}

@Test
void 운영_프로필에서_ngrok_주소면_기동하지_않는다() {
runner.withPropertyValues("spring.profiles.active=prod",
"app.callback-base-url=https://wildcat-startle-rope.ngrok-free.dev")
.run(context -> assertThat(context).hasFailed());
runner.withPropertyValues("spring.profiles.active=prod", "app.callback-base-url=https://api.repit.example")
.run(context -> assertThat(context).hasNotFailed());
}

@Test
void 내부_인증을_필수로_켰는데_인증값이_없으면_기동하지_않는다() {
runner.withPropertyValues("app.callback-base-url=https://api.repit.example",
"app.internal-auth.required=true", "app.internal-auth.token= ")
.run(context -> assertThat(context).hasFailed()
.getFailure().rootCause().hasMessageContaining("INTERNAL_AUTH_TOKEN"));
}

@Test
void 내부_인증이_필수가_아니면_인증값_없이도_기동한다() {
runner.withPropertyValues("app.callback-base-url=https://api.repit.example")
.run(context -> assertThat(context).hasNotFailed());
runner.withPropertyValues("app.callback-base-url=https://api.repit.example",
"app.internal-auth.required=true", "app.internal-auth.token=secret-token-value")
.run(context -> assertThat(context).hasNotFailed());
}

/** 사용자 정보·쿼리를 막는 검사라, 원문을 오류에 실으면 그 안의 자격 증명이 기동 실패 로그에 남는다. */
@Test
void 오류_메시지에_주소_원문을_싣지_않는다() {
assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(
"https://user:secret-password@api.repit.example?token=query-secret", false))
.isInstanceOf(IllegalStateException.class)
.message()
.doesNotContain("secret-password", "query-secret", "user:")
.contains("api.repit.example");
}
}
4 changes: 4 additions & 0 deletions src/test/resources/config/application.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 테스트 전용 설정. classpath:/config/ 파일이라 기본 application.yaml 위에 겹쳐 읽힌다.
app:
# 테스트는 외부 서버를 부르지 않는다. 기본값이 없어진 콜백 주소를 명시적으로 채워 기동 검사를 통과시킨다.
callback-base-url: https://api.repit.test
Loading