update project configuration and add middleware tests
This commit is contained in:
parent
b0345b644a
commit
cb35ec5a1a
9 changed files with 155 additions and 8 deletions
2
Makefile
2
Makefile
|
|
@ -1,6 +1,8 @@
|
|||
export APP_ENV ?= local
|
||||
export HTTP_ADDR ?= :8080
|
||||
export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable
|
||||
export REDIS_URL ?= redis://localhost:6379/0
|
||||
export AUTH_USERNAME ?= nomadcode
|
||||
export GOOSE_DRIVER ?= postgres
|
||||
export GOOSE_DBSTRING ?= $(DATABASE_URL)
|
||||
export OUTPUT ?= .build/nomadcode-core
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -15,7 +15,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를
|
|||
- River dummy job
|
||||
- Plane Adapter stub
|
||||
- Mattermost Adapter stub
|
||||
- 선택적 Docker Compose 실행 환경
|
||||
- 선택적 Docker Compose 실행 환경(PostgreSQL, Redis)
|
||||
|
||||
## 현재 구현하지 않은 범위
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를
|
|||
|
||||
## 실행 방법
|
||||
|
||||
로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다.
|
||||
로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. Redis는 이후 event fan-out / realtime push 경로를 위한 선택 구성으로, Docker Compose에서는 `redis://redis:6379/0`로 노출합니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다.
|
||||
|
||||
PostgreSQL 사용자와 DB 생성 예시:
|
||||
|
||||
|
|
@ -81,6 +81,13 @@ Migration 실행:
|
|||
./bin/run
|
||||
```
|
||||
|
||||
간단한 암호를 걸고 실행:
|
||||
|
||||
```bash
|
||||
AUTH_PASSWORD="change-me" ./bin/run
|
||||
curl -u nomadcode:change-me localhost:8080/api/tasks
|
||||
```
|
||||
|
||||
다른 DB 주소를 사용할 경우:
|
||||
|
||||
```bash
|
||||
|
|
@ -102,10 +109,10 @@ sqlc 실행:
|
|||
|
||||
DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으로 `internal/db/`에 생성합니다. `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 생성 파일이므로 직접 수정하지 않습니다.
|
||||
|
||||
Docker Compose 실행은 통합 확인이 필요할 때 선택적으로 사용합니다.
|
||||
Docker Compose 실행은 PostgreSQL과 Redis를 함께 띄워 통합 확인이 필요할 때 선택적으로 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다.
|
||||
|
||||
```bash
|
||||
./bin/docker-up
|
||||
AUTH_PASSWORD="change-me" ./bin/docker-up
|
||||
```
|
||||
|
||||
Makefile은 같은 명령을 감싸는 얇은 alias입니다.
|
||||
|
|
|
|||
2
bin/run
2
bin/run
|
|
@ -7,5 +7,7 @@ cd "$ROOT_DIR"
|
|||
export APP_ENV="${APP_ENV:-local}"
|
||||
export HTTP_ADDR="${HTTP_ADDR:-:8080}"
|
||||
export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}"
|
||||
export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}"
|
||||
export AUTH_USERNAME="${AUTH_USERNAME:-nomadcode}"
|
||||
|
||||
exec go run ./cmd/server "$@"
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func run(logger *slog.Logger) error {
|
|||
handler := apphttp.NewHandler(pool, workflowService, logger)
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: apphttp.NewRouter(handler, logger),
|
||||
Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,28 @@ services:
|
|||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
nomadcode-core:
|
||||
build: .
|
||||
environment:
|
||||
APP_ENV: local
|
||||
HTTP_ADDR: :8080
|
||||
DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
AUTH_USERNAME: ${AUTH_USERNAME:-nomadcode}
|
||||
AUTH_PASSWORD: ${AUTH_PASSWORD:-}
|
||||
MATTERMOST_BASE_URL: ""
|
||||
MATTERMOST_TOKEN: ""
|
||||
PLANE_BASE_URL: ""
|
||||
|
|
@ -28,8 +44,11 @@ services:
|
|||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ type Config struct {
|
|||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
AuthUsername string
|
||||
AuthPassword string
|
||||
MattermostBaseURL string
|
||||
MattermostToken string
|
||||
PlaneBaseURL string
|
||||
|
|
@ -17,6 +20,9 @@ func Load() Config {
|
|||
AppEnv: getEnv("APP_ENV", "local"),
|
||||
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
AuthUsername: getEnv("AUTH_USERNAME", "nomadcode"),
|
||||
AuthPassword: os.Getenv("AUTH_PASSWORD"),
|
||||
MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"),
|
||||
MattermostToken: os.Getenv("MATTERMOST_TOKEN"),
|
||||
PlaneBaseURL: os.Getenv("PLANE_BASE_URL"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"time"
|
||||
|
|
@ -35,3 +36,31 @@ func loggingMiddleware(logger *slog.Logger) func(stdhttp.Handler) stdhttp.Handle
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func basicAuthMiddleware(auth AuthConfig) func(stdhttp.Handler) stdhttp.Handler {
|
||||
if auth.Password == "" {
|
||||
return func(next stdhttp.Handler) stdhttp.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
if auth.Username == "" {
|
||||
auth.Username = "nomadcode"
|
||||
}
|
||||
|
||||
return func(next stdhttp.Handler) stdhttp.Handler {
|
||||
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || !secureCompare(username, auth.Username) || !secureCompare(password, auth.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="nomadcode-core", charset="UTF-8"`)
|
||||
writeError(w, stdhttp.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func secureCompare(got, want string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||
}
|
||||
|
|
|
|||
73
internal/http/middleware_test.go
Normal file
73
internal/http/middleware_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBasicAuthMiddlewareDisabledWithoutPassword(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
basicAuthMiddleware(AuthConfig{})(okHandler()).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareRejectsMissingCredentials(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
|
||||
t.Fatal("expected WWW-Authenticate header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddlewareAcceptsValidCredentials(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
req.SetBasicAuth("nomadcode", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterKeepsHealthzPublicAndProtectsAPI(t *testing.T) {
|
||||
router := NewRouter(NewHandler(nil, nil, nil), nil, AuthConfig{
|
||||
Username: "nomadcode",
|
||||
Password: "secret",
|
||||
})
|
||||
|
||||
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
healthRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(healthRec, healthReq)
|
||||
|
||||
if healthRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected /healthz status %d, got %d", http.StatusOK, healthRec.Code)
|
||||
}
|
||||
|
||||
apiReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
apiRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(apiRec, apiReq)
|
||||
|
||||
if apiRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected /api/tasks status %d, got %d", http.StatusUnauthorized, apiRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
|
@ -8,7 +8,12 @@ import (
|
|||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler {
|
||||
type AuthConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.RequestID)
|
||||
r.Use(chimiddleware.RealIP)
|
||||
|
|
@ -16,14 +21,18 @@ func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler {
|
|||
r.Use(chimiddleware.Recoverer)
|
||||
|
||||
r.Get("/healthz", handler.Healthz)
|
||||
r.Get("/readyz", handler.Readyz)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
protected := chi.NewRouter()
|
||||
protected.Use(basicAuthMiddleware(auth))
|
||||
protected.Get("/readyz", handler.Readyz)
|
||||
|
||||
protected.Route("/api", func(r chi.Router) {
|
||||
r.Post("/tasks", handler.CreateTask)
|
||||
r.Get("/tasks", handler.ListTasks)
|
||||
r.Get("/tasks/{id}", handler.GetTask)
|
||||
r.Post("/tasks/{id}/enqueue", handler.EnqueueTask)
|
||||
})
|
||||
r.Mount("/", protected)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue