Skip to content

Commit 7ff30f4

Browse files
committed
refactor: cors 처리 사용자 config 처리하도록 개선, null 처리 개선, test 추가
1 parent a51c2cc commit 7ff30f4

2 files changed

Lines changed: 132 additions & 31 deletions

File tree

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,70 @@
11
package sprout.core.filter.cors;
22

33
import sprout.beans.annotation.Component;
4+
import sprout.config.AppConfig;
45
import sprout.core.filter.Filter;
56
import sprout.core.filter.FilterChain;
7+
import sprout.mvc.http.HttpMethod;
68
import sprout.mvc.http.HttpRequest;
79
import sprout.mvc.http.HttpResponse;
8-
import sprout.mvc.http.HttpMethod;
910

1011
import java.io.IOException;
12+
import java.util.Optional;
1113

1214
@Component
1315
public class CorsFilter implements Filter {
16+
private final AppConfig appConfig;
17+
18+
public CorsFilter(AppConfig appConfig) {
19+
this.appConfig = appConfig;
20+
}
21+
1422
@Override
1523
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) throws IOException {
16-
response.addHeader("Access-Control-Allow-Origin", "*");
17-
response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
18-
response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
24+
String origin = Optional.ofNullable(request.getHeaders().get("Origin"))
25+
.map(Object::toString)
26+
.orElse(null);
27+
28+
if (origin == null || origin.isEmpty()) {
29+
chain.doFilter(request, response);
30+
return;
31+
}
32+
33+
String allowOrigin = appConfig.getStringProperty("cors.allow-origin", "*");
34+
boolean allowCredentials = Boolean.parseBoolean(appConfig.getStringProperty("cors.allow-credentials", "false"));
35+
String allowMethods = appConfig.getStringProperty("cors.allow-methods", "GET, POST, PUT, DELETE, OPTIONS");
36+
String allowHeaders = appConfig.getStringProperty("cors.allow-headers", "Content-Type, Authorization");
37+
String exposeHeaders = appConfig.getStringProperty("cors.expose-headers", "");
38+
String maxAge = appConfig.getStringProperty("cors.max-age", "3600");
39+
40+
String resolvedOrigin = allowCredentials && "*".equals(allowOrigin) ? origin : allowOrigin;
41+
42+
response.addHeader("Vary", "Origin");
43+
response.addHeader("Vary", "Access-Control-Request-Method");
44+
response.addHeader("Vary", "Access-Control-Request-Headers");
45+
response.addHeader("Access-Control-Allow-Origin", resolvedOrigin);
46+
if (allowCredentials) response.addHeader("Access-Control-Allow-Credentials", "true");
1947

2048
if (request.getMethod().equals(HttpMethod.OPTIONS)) {
21-
response.addHeader("Access-Control-Max-Age", "3600");
49+
String reqMethod = Optional.ofNullable(request.getHeaders().get("Access-Control-Request-Method"))
50+
.map(Object::toString)
51+
.orElse(allowMethods);
52+
String reqHeaders = Optional.ofNullable(request.getHeaders().get("Access-Control-Request-Headers"))
53+
.map(Object::toString)
54+
.orElse(allowHeaders);
55+
56+
response.addHeader("Access-Control-Allow-Methods", reqMethod);
57+
response.addHeader("Access-Control-Allow-Headers", reqHeaders);
58+
response.addHeader("Access-Control-Max-Age", maxAge);
59+
response.addHeader("Content-Length", "0");
2260
return;
2361
}
2462

63+
response.addHeader("Access-Control-Allow-Methods", allowMethods);
64+
response.addHeader("Access-Control-Allow-Headers", allowHeaders);
65+
if (!exposeHeaders.isBlank()) response.addHeader("Access-Control-Expose-Headers", exposeHeaders);
66+
2567
chain.doFilter(request, response);
2668
}
69+
2770
}
Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
package sprout.core.filter.cors;
22

3-
import org.junit.jupiter.api.BeforeEach;
4-
import org.junit.jupiter.api.DisplayName;
5-
import org.junit.jupiter.api.Nested;
6-
import org.junit.jupiter.api.Test;
3+
import org.junit.jupiter.api.*;
74
import org.mockito.InOrder;
85
import org.mockito.Mock;
96
import org.mockito.MockitoAnnotations;
7+
import sprout.config.AppConfig;
108
import sprout.core.filter.FilterChain;
119
import sprout.mvc.http.HttpMethod;
1210
import sprout.mvc.http.HttpRequest;
1311
import sprout.mvc.http.HttpResponse;
1412

1513
import java.io.IOException;
14+
import java.util.Map;
1615

1716
import static org.mockito.Mockito.*;
1817

@@ -23,61 +22,120 @@ class CorsFilterTest {
2322
@Mock HttpRequest request;
2423
@Mock HttpResponse response;
2524
@Mock FilterChain chain;
25+
@Mock AppConfig appConfig;
26+
27+
AutoCloseable mocks;
2628

2729
@BeforeEach
2830
void setUp() {
29-
MockitoAnnotations.openMocks(this);
30-
corsFilter = new CorsFilter();
31+
mocks = MockitoAnnotations.openMocks(this);
32+
corsFilter = new CorsFilter(appConfig);
33+
when(appConfig.getStringProperty(eq("cors.allow-origin"), anyString())).thenReturn("*");
34+
when(appConfig.getStringProperty(eq("cors.allow-credentials"), anyString())).thenReturn("false");
35+
when(appConfig.getStringProperty(eq("cors.allow-methods"), anyString())).thenReturn("GET, POST, PUT, DELETE, OPTIONS");
36+
when(appConfig.getStringProperty(eq("cors.allow-headers"), anyString())).thenReturn("Content-Type, Authorization");
37+
when(appConfig.getStringProperty(eq("cors.expose-headers"), anyString())).thenReturn("");
38+
when(appConfig.getStringProperty(eq("cors.max-age"), anyString())).thenReturn("3600");
39+
}
40+
41+
@AfterEach
42+
void tearDown() throws Exception {
43+
if (mocks != null) mocks.close();
3144
}
3245

33-
/* ---------- OPTIONS 사전 요청 ---------- */
46+
// --- 작은 헬퍼: request.getHeaders()를 항상 목으로 주입 ---
47+
@SuppressWarnings("unchecked")
48+
private Map<String, Object> headers() {
49+
Map<String, Object> h = (Map<String, Object>) mock(Map.class);
50+
when(request.getHeaders()).thenReturn(h);
51+
return h;
52+
}
3453

3554
@Nested
36-
@DisplayName("OPTIONS pre‑flight 요청")
37-
class Options {
55+
@DisplayName("Origin 헤더가 없는 요청")
56+
class NoOrigin {
57+
@Test
58+
void skipsCorsAndForwardsChain() throws IOException {
59+
Map<String, Object> h = headers();
60+
when(h.get("Origin")).thenReturn(null);
61+
when(request.getMethod()).thenReturn(HttpMethod.GET);
3862

63+
corsFilter.doFilter(request, response, chain);
64+
65+
verify(chain).doFilter(request, response);
66+
verify(response, never()).addHeader(eq("Access-Control-Allow-Origin"), anyString());
67+
}
68+
}
69+
70+
@Nested
71+
@DisplayName("OPTIONS pre-flight 요청")
72+
class Options {
3973
@Test
40-
@DisplayName("Max‑Age 헤더를 추가하고 체인을 진행하지 않는다")
41-
void handlesOptionsWithoutCallingChain() throws IOException {
74+
@DisplayName("Max-Age와 Allow-*를 설정하고 단락한다")
75+
void handlesOptionsPreflight() throws IOException {
76+
Map<String, Object> h = headers();
77+
when(h.get("Origin")).thenReturn("https://app.example.com");
78+
when(h.get("Access-Control-Request-Method")).thenReturn("PATCH");
79+
when(h.get("Access-Control-Request-Headers")).thenReturn("X-Trace-Id, Authorization");
4280
when(request.getMethod()).thenReturn(HttpMethod.OPTIONS);
4381

4482
corsFilter.doFilter(request, response, chain);
4583

46-
// 공통 CORS 헤더 + Max‑Age 검증
47-
verify(response).addHeader("Access-Control-Allow-Origin", "*");
48-
verify(response).addHeader("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS");
49-
verify(response).addHeader("Access-Control-Allow-Headers","Content-Type, Authorization");
50-
verify(response).addHeader("Access-Control-Max-Age", "3600");
51-
52-
// 체인이 호출되지 않아야 한다
84+
verify(response).addHeader("Vary","Origin");
85+
verify(response).addHeader("Vary","Access-Control-Request-Method");
86+
verify(response).addHeader("Vary","Access-Control-Request-Headers");
87+
verify(response).addHeader("Access-Control-Allow-Origin","*");
88+
verify(response).addHeader("Access-Control-Allow-Methods","PATCH");
89+
verify(response).addHeader("Access-Control-Allow-Headers","X-Trace-Id, Authorization");
90+
verify(response).addHeader("Access-Control-Max-Age","3600");
91+
verify(response).addHeader("Content-Length","0");
5392
verifyNoInteractions(chain);
5493
}
5594
}
5695

57-
/* ---------- 일반(비‑OPTIONS) 요청 ---------- */
58-
5996
@Nested
6097
@DisplayName("일반 HTTP 요청")
6198
class NonOptions {
62-
6399
@Test
64-
@DisplayName("CORS 헤더를 추가한 뒤 체인을 계속 진행한다")
100+
@DisplayName("CORS 헤더를 추가하고 체인을 계속 진행한다")
65101
void addsHeadersAndForwards() throws IOException {
102+
Map<String, Object> h = headers();
103+
when(h.get("Origin")).thenReturn("https://app.example.com");
66104
when(request.getMethod()).thenReturn(HttpMethod.GET);
67105

68106
corsFilter.doFilter(request, response, chain);
69107

70-
// 헤더 추가 순서까지 확인 (선택)
71108
InOrder in = inOrder(response, chain);
109+
in.verify(response).addHeader("Vary","Origin");
110+
in.verify(response).addHeader("Vary","Access-Control-Request-Method");
111+
in.verify(response).addHeader("Vary","Access-Control-Request-Headers");
72112
in.verify(response).addHeader("Access-Control-Allow-Origin","*");
73113
in.verify(response).addHeader("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS");
74114
in.verify(response).addHeader("Access-Control-Allow-Headers","Content-Type, Authorization");
115+
in.verify(chain).doFilter(request, response);
75116

76-
// Max‑Age 헤더는 없음
77117
verify(response, never()).addHeader(eq("Access-Control-Max-Age"), anyString());
118+
}
119+
}
78120

79-
// 체인 진행
80-
in.verify(chain).doFilter(request, response);
121+
@Nested
122+
@DisplayName("Credentials 허용 시")
123+
class Credentials {
124+
@Test
125+
@DisplayName("와일드카드 대신 요청 Origin을 반사한다")
126+
void reflectsOriginWhenCredentialsTrue() throws IOException {
127+
when(appConfig.getStringProperty("cors.allow-credentials","false")).thenReturn("true");
128+
when(appConfig.getStringProperty("cors.allow-origin","*")).thenReturn("*");
129+
130+
Map<String, Object> h = headers();
131+
when(h.get("Origin")).thenReturn("https://secure.example.com");
132+
when(request.getMethod()).thenReturn(HttpMethod.GET);
133+
134+
corsFilter.doFilter(request, response, chain);
135+
136+
verify(response).addHeader("Access-Control-Allow-Origin","https://secure.example.com");
137+
verify(response).addHeader("Access-Control-Allow-Credentials","true");
138+
verify(chain).doFilter(request, response);
81139
}
82140
}
83141
}

0 commit comments

Comments
 (0)