Skip to content

Commit 70c816e

Browse files
committed
feat: add tests for event dispatcher, invoker, and pipeline error handling
- Implement tests for DefaultEventDispatcher to ensure error propagation from publishers. - Add tests for Invoker to verify method invocation and error handling. - Enhance pipeline execution with error recovery and post-hook failure handling. - Introduce WebSocket options for connection handling, including origin checks and message size limits. - Update HTTP options to include body size limits and timeouts. - Add integration tests for WebSocket connections and message handling.
1 parent 78b74a5 commit 70c816e

24 files changed

Lines changed: 1957 additions & 105 deletions

interceptor/cors/cors_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cors
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/NARUBROWN/spine/core"
9+
)
10+
11+
type testExecutionContext struct {
12+
method string
13+
headers map[string]string
14+
store map[string]any
15+
}
16+
17+
func newTestExecutionContext(method string) *testExecutionContext {
18+
return &testExecutionContext{
19+
method: method,
20+
headers: map[string]string{},
21+
store: map[string]any{},
22+
}
23+
}
24+
25+
func (c *testExecutionContext) Context() context.Context { return context.Background() }
26+
func (c *testExecutionContext) EventBus() core.EventBus { return nil }
27+
func (c *testExecutionContext) Method() string { return c.method }
28+
func (c *testExecutionContext) Path() string { return "/" }
29+
func (c *testExecutionContext) Params() map[string]string { return map[string]string{} }
30+
func (c *testExecutionContext) Header(name string) string { return c.headers[name] }
31+
func (c *testExecutionContext) PathKeys() []string { return nil }
32+
func (c *testExecutionContext) Queries() map[string][]string { return map[string][]string{} }
33+
func (c *testExecutionContext) Set(key string, value any) { c.store[key] = value }
34+
func (c *testExecutionContext) Get(key string) (any, bool) { v, ok := c.store[key]; return v, ok }
35+
36+
type testResponseWriter struct {
37+
headers map[string]string
38+
status int
39+
}
40+
41+
func newTestResponseWriter() *testResponseWriter {
42+
return &testResponseWriter{headers: map[string]string{}}
43+
}
44+
45+
func (w *testResponseWriter) SetHeader(key, value string) { w.headers[key] = value }
46+
func (w *testResponseWriter) AddHeader(key, value string) { w.headers[key] = value }
47+
func (w *testResponseWriter) IsCommitted() bool { return false }
48+
func (w *testResponseWriter) WriteStatus(status int) error {
49+
w.status = status
50+
return nil
51+
}
52+
func (w *testResponseWriter) WriteJSON(status int, value any) error {
53+
w.status = status
54+
return nil
55+
}
56+
func (w *testResponseWriter) WriteString(status int, value string) error {
57+
w.status = status
58+
return nil
59+
}
60+
func (w *testResponseWriter) WriteBytes(status int, value []byte) error {
61+
w.status = status
62+
return nil
63+
}
64+
65+
func TestCORSInterceptor_PreflightAllowedOrigin(t *testing.T) {
66+
interceptor := New(Config{
67+
AllowOrigins: []string{"https://app.example"},
68+
AllowMethods: []string{"GET", "POST"},
69+
AllowHeaders: []string{"Content-Type", "Authorization"},
70+
AllowCredentials: true,
71+
})
72+
73+
ctx := newTestExecutionContext("OPTIONS")
74+
ctx.headers["Origin"] = "https://app.example"
75+
76+
writer := newTestResponseWriter()
77+
ctx.Set("spine.response_writer", writer)
78+
79+
err := interceptor.PreHandle(ctx, core.HandlerMeta{})
80+
if !errors.Is(err, core.ErrAbortPipeline) {
81+
t.Fatalf("preflight는 파이프라인을 중단해야 합니다: %v", err)
82+
}
83+
84+
if writer.status != 204 {
85+
t.Fatalf("preflight 상태 코드는 204여야 합니다: %d", writer.status)
86+
}
87+
if writer.headers["Access-Control-Allow-Origin"] != "https://app.example" {
88+
t.Fatalf("Allow-Origin 헤더가 잘못되었습니다: %v", writer.headers)
89+
}
90+
if writer.headers["Vary"] != "Origin" {
91+
t.Fatalf("Vary 헤더가 누락되었습니다: %v", writer.headers)
92+
}
93+
if writer.headers["Access-Control-Allow-Credentials"] != "true" {
94+
t.Fatalf("Allow-Credentials 헤더가 누락되었습니다: %v", writer.headers)
95+
}
96+
if writer.headers["Access-Control-Allow-Methods"] != "GET, POST" {
97+
t.Fatalf("Allow-Methods 헤더가 잘못되었습니다: %v", writer.headers)
98+
}
99+
if writer.headers["Access-Control-Allow-Headers"] != "Content-Type, Authorization" {
100+
t.Fatalf("Allow-Headers 헤더가 잘못되었습니다: %v", writer.headers)
101+
}
102+
}
103+
104+
func TestCORSInterceptor_DisallowedOriginOmitsAllowOrigin(t *testing.T) {
105+
interceptor := New(Config{
106+
AllowOrigins: []string{"https://app.example"},
107+
AllowMethods: []string{"GET"},
108+
AllowHeaders: []string{"Content-Type"},
109+
})
110+
111+
ctx := newTestExecutionContext("GET")
112+
ctx.headers["Origin"] = "https://evil.example"
113+
114+
writer := newTestResponseWriter()
115+
ctx.Set("spine.response_writer", writer)
116+
117+
if err := interceptor.PreHandle(ctx, core.HandlerMeta{}); err != nil {
118+
t.Fatalf("예상하지 못한 에러입니다: %v", err)
119+
}
120+
121+
if _, ok := writer.headers["Access-Control-Allow-Origin"]; ok {
122+
t.Fatalf("허용되지 않은 origin에는 Allow-Origin 헤더가 없어야 합니다: %v", writer.headers)
123+
}
124+
if writer.headers["Access-Control-Allow-Methods"] != "GET" {
125+
t.Fatalf("Allow-Methods는 항상 설정되어야 합니다: %v", writer.headers)
126+
}
127+
}
128+
129+
func TestCORSInterceptor_NoResponseWriterIsNoop(t *testing.T) {
130+
interceptor := New(Config{})
131+
ctx := newTestExecutionContext("GET")
132+
133+
if err := interceptor.PreHandle(ctx, core.HandlerMeta{}); err != nil {
134+
t.Fatalf("ResponseWriter가 없으면 no-op 이어야 합니다: %v", err)
135+
}
136+
}

internal/adapter/echo/adapter.go

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,108 @@ package echo
33
import (
44
"context"
55
"net/http"
6+
"time"
67

78
"github.com/NARUBROWN/spine/internal/pipeline"
9+
"github.com/NARUBROWN/spine/pkg/boot"
810
"github.com/labstack/echo/v4"
911
"github.com/labstack/gommon/log"
1012
)
1113

14+
const (
15+
defaultReadHeaderTimeout = 5 * time.Second
16+
defaultReadTimeout = 30 * time.Second
17+
defaultWriteTimeout = 30 * time.Second
18+
defaultIdleTimeout = 120 * time.Second
19+
defaultMaxHeaderBytes = 1 << 20
20+
defaultMaxBodyBytes = 32 << 20
21+
)
22+
23+
type normalizedHTTPOptions struct {
24+
DisableRecover bool
25+
ReadHeaderTimeout time.Duration
26+
ReadTimeout time.Duration
27+
WriteTimeout time.Duration
28+
IdleTimeout time.Duration
29+
MaxHeaderBytes int
30+
MaxBodyBytes int64
31+
}
32+
1233
type Server struct {
1334
echo *echo.Echo
1435
pipeline *pipeline.Pipeline
1536
address string
1637
transportHooks []func(any)
38+
httpServer *http.Server
39+
maxBodyBytes int64
1740
}
1841

19-
func NewServer(pipeline *pipeline.Pipeline, address string, transportHooks []func(any), recoverEnabled bool) *Server {
20-
e := newEcho(recoverEnabled)
42+
func NewServer(pipeline *pipeline.Pipeline, address string, transportHooks []func(any), opts boot.HTTPOptions) *Server {
43+
normalized := normalizeHTTPOptions(opts)
44+
e := newEcho(normalized)
2145
for _, hook := range transportHooks {
2246
hook(e)
2347
}
2448

49+
httpServer := &http.Server{
50+
Addr: address,
51+
Handler: e,
52+
ReadHeaderTimeout: normalized.ReadHeaderTimeout,
53+
ReadTimeout: normalized.ReadTimeout,
54+
WriteTimeout: normalized.WriteTimeout,
55+
IdleTimeout: normalized.IdleTimeout,
56+
MaxHeaderBytes: normalized.MaxHeaderBytes,
57+
}
58+
2559
return &Server{
2660
echo: e,
2761
pipeline: pipeline,
2862
address: address,
2963
transportHooks: transportHooks,
64+
httpServer: httpServer,
65+
maxBodyBytes: normalized.MaxBodyBytes,
3066
}
3167
}
3268

33-
func newEcho(recoverEnabled bool) *echo.Echo {
69+
func normalizeHTTPOptions(opts boot.HTTPOptions) normalizedHTTPOptions {
70+
normalized := normalizedHTTPOptions{
71+
DisableRecover: opts.DisableRecover,
72+
ReadHeaderTimeout: opts.ReadHeaderTimeout,
73+
ReadTimeout: opts.ReadTimeout,
74+
WriteTimeout: opts.WriteTimeout,
75+
IdleTimeout: opts.IdleTimeout,
76+
MaxHeaderBytes: opts.MaxHeaderBytes,
77+
MaxBodyBytes: opts.MaxBodyBytes,
78+
}
79+
80+
if normalized.ReadHeaderTimeout == 0 {
81+
normalized.ReadHeaderTimeout = defaultReadHeaderTimeout
82+
}
83+
if normalized.ReadTimeout == 0 {
84+
normalized.ReadTimeout = defaultReadTimeout
85+
}
86+
if normalized.WriteTimeout == 0 {
87+
normalized.WriteTimeout = defaultWriteTimeout
88+
}
89+
if normalized.IdleTimeout == 0 {
90+
normalized.IdleTimeout = defaultIdleTimeout
91+
}
92+
if normalized.MaxHeaderBytes == 0 {
93+
normalized.MaxHeaderBytes = defaultMaxHeaderBytes
94+
}
95+
if normalized.MaxBodyBytes == 0 {
96+
normalized.MaxBodyBytes = defaultMaxBodyBytes
97+
}
98+
99+
return normalized
100+
}
101+
102+
func newEcho(opts normalizedHTTPOptions) *echo.Echo {
34103
e := echo.New()
35104
e.HideBanner = true
36105
e.HidePort = true
37106
e.Logger.SetLevel(log.ERROR)
38-
if recoverEnabled {
107+
if !opts.DisableRecover {
39108
e.Use(simpleRecover())
40109
}
41110
return e
@@ -63,10 +132,16 @@ func (s *Server) Mount() {
63132
}
64133

65134
func (s *Server) Start() error {
66-
return s.echo.Start(s.address)
135+
return s.httpServer.ListenAndServe()
67136
}
68137

69138
func (s *Server) handle(c echo.Context) error {
139+
if s.maxBodyBytes > 0 {
140+
req := c.Request()
141+
req.Body = http.MaxBytesReader(c.Response(), req.Body, s.maxBodyBytes)
142+
c.SetRequest(req)
143+
}
144+
70145
ctx := NewContext(c)
71146

72147
ctx.Set(
@@ -83,5 +158,5 @@ func (s *Server) handle(c echo.Context) error {
83158
}
84159

85160
func (s *Server) Shutdown(ctx context.Context) error {
86-
return s.echo.Shutdown(ctx)
161+
return s.httpServer.Shutdown(ctx)
87162
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package echo
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/NARUBROWN/spine/pkg/boot"
8+
)
9+
10+
func TestNewServer_AppliesSecureDefaults(t *testing.T) {
11+
server := NewServer(nil, ":0", nil, boot.HTTPOptions{})
12+
13+
if server.httpServer.ReadHeaderTimeout != defaultReadHeaderTimeout {
14+
t.Fatalf("ReadHeaderTimeout 기본값이 적용되지 않았습니다: %s", server.httpServer.ReadHeaderTimeout)
15+
}
16+
if server.httpServer.ReadTimeout != defaultReadTimeout {
17+
t.Fatalf("ReadTimeout 기본값이 적용되지 않았습니다: %s", server.httpServer.ReadTimeout)
18+
}
19+
if server.httpServer.WriteTimeout != defaultWriteTimeout {
20+
t.Fatalf("WriteTimeout 기본값이 적용되지 않았습니다: %s", server.httpServer.WriteTimeout)
21+
}
22+
if server.httpServer.IdleTimeout != defaultIdleTimeout {
23+
t.Fatalf("IdleTimeout 기본값이 적용되지 않았습니다: %s", server.httpServer.IdleTimeout)
24+
}
25+
if server.httpServer.MaxHeaderBytes != defaultMaxHeaderBytes {
26+
t.Fatalf("MaxHeaderBytes 기본값이 적용되지 않았습니다: %d", server.httpServer.MaxHeaderBytes)
27+
}
28+
}
29+
30+
func TestNewServer_AppliesCustomOptions(t *testing.T) {
31+
server := NewServer(nil, ":0", nil, boot.HTTPOptions{
32+
ReadHeaderTimeout: 2 * time.Second,
33+
ReadTimeout: 3 * time.Second,
34+
WriteTimeout: 4 * time.Second,
35+
IdleTimeout: 5 * time.Second,
36+
MaxHeaderBytes: 4096,
37+
MaxBodyBytes: 128,
38+
})
39+
40+
if server.httpServer.ReadHeaderTimeout != 2*time.Second {
41+
t.Fatalf("ReadHeaderTimeout가 반영되지 않았습니다: %s", server.httpServer.ReadHeaderTimeout)
42+
}
43+
if server.httpServer.ReadTimeout != 3*time.Second {
44+
t.Fatalf("ReadTimeout가 반영되지 않았습니다: %s", server.httpServer.ReadTimeout)
45+
}
46+
if server.httpServer.WriteTimeout != 4*time.Second {
47+
t.Fatalf("WriteTimeout가 반영되지 않았습니다: %s", server.httpServer.WriteTimeout)
48+
}
49+
if server.httpServer.IdleTimeout != 5*time.Second {
50+
t.Fatalf("IdleTimeout가 반영되지 않았습니다: %s", server.httpServer.IdleTimeout)
51+
}
52+
if server.httpServer.MaxHeaderBytes != 4096 {
53+
t.Fatalf("MaxHeaderBytes가 반영되지 않았습니다: %d", server.httpServer.MaxHeaderBytes)
54+
}
55+
}

internal/bootstrap/bootstrap.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ func Run(config Config) error {
347347
// WS 전용 ArgumentResolver 등록
348348
wsPipeline := buildWSPipeline(container, config.WebSocketRegistry, dispatchHook)
349349

350-
wsRuntime = ws.NewRuntime(config.WebSocketRegistry, wsPipeline)
350+
wsRuntime = ws.NewRuntime(config.WebSocketRegistry, wsPipeline, config.HTTP.WebSocket)
351351
defer wsRuntime.Stop()
352352

353353
// Echo Transport Hook으로 마운트
@@ -367,8 +367,7 @@ func Run(config Config) error {
367367
}
368368

369369
// Echo Adapter
370-
recoverEnabled := config.HTTP == nil || !config.HTTP.DisableRecover
371-
server = httpEngine.NewServer(httpPipeline, config.Address, config.TransportHooks, recoverEnabled)
370+
server = httpEngine.NewServer(httpPipeline, config.Address, config.TransportHooks, *config.HTTP)
372371
server.Mount()
373372

374373
log.Printf("[Bootstrap] 서버 리스닝 시작: %s", config.Address)
@@ -636,7 +635,7 @@ ____/ /__ /_/ / / _ / / / __/
636635

637636
func printBanner() {
638637
fmt.Print(spineBanner)
639-
log.Printf("[Bootstrap] Spine version: %s", "v0.4.2")
638+
log.Printf("[Bootstrap] Spine version: %s", "v0.4.3")
640639
}
641640

642641
func buildConsumerPipeline(container *container.Container, registry *consumer.Registry, dispatchHook *hook.EventDispatchHook) *pipeline.Pipeline {

0 commit comments

Comments
 (0)