update project configuration and add middleware tests

This commit is contained in:
toki 2026-05-19 13:43:19 +09:00
parent b0345b644a
commit cb35ec5a1a
9 changed files with 155 additions and 8 deletions

View file

@ -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

View file

@ -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입니다.

View file

@ -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 "$@"

View file

@ -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,
}

View file

@ -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:

View file

@ -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"),

View file

@ -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
}

View 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)
})
}

View file

@ -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
}