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 APP_ENV ?= local
|
||||||
export HTTP_ADDR ?= :8080
|
export HTTP_ADDR ?= :8080
|
||||||
export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable
|
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_DRIVER ?= postgres
|
||||||
export GOOSE_DBSTRING ?= $(DATABASE_URL)
|
export GOOSE_DBSTRING ?= $(DATABASE_URL)
|
||||||
export OUTPUT ?= .build/nomadcode-core
|
export OUTPUT ?= .build/nomadcode-core
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -15,7 +15,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를
|
||||||
- River dummy job
|
- River dummy job
|
||||||
- Plane Adapter stub
|
- Plane Adapter stub
|
||||||
- Mattermost 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 생성 예시:
|
PostgreSQL 사용자와 DB 생성 예시:
|
||||||
|
|
||||||
|
|
@ -81,6 +81,13 @@ Migration 실행:
|
||||||
./bin/run
|
./bin/run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
간단한 암호를 걸고 실행:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_PASSWORD="change-me" ./bin/run
|
||||||
|
curl -u nomadcode:change-me localhost:8080/api/tasks
|
||||||
|
```
|
||||||
|
|
||||||
다른 DB 주소를 사용할 경우:
|
다른 DB 주소를 사용할 경우:
|
||||||
|
|
||||||
```bash
|
```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`는 생성 파일이므로 직접 수정하지 않습니다.
|
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
|
```bash
|
||||||
./bin/docker-up
|
AUTH_PASSWORD="change-me" ./bin/docker-up
|
||||||
```
|
```
|
||||||
|
|
||||||
Makefile은 같은 명령을 감싸는 얇은 alias입니다.
|
Makefile은 같은 명령을 감싸는 얇은 alias입니다.
|
||||||
|
|
|
||||||
2
bin/run
2
bin/run
|
|
@ -7,5 +7,7 @@ cd "$ROOT_DIR"
|
||||||
export APP_ENV="${APP_ENV:-local}"
|
export APP_ENV="${APP_ENV:-local}"
|
||||||
export HTTP_ADDR="${HTTP_ADDR:-:8080}"
|
export HTTP_ADDR="${HTTP_ADDR:-:8080}"
|
||||||
export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}"
|
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 "$@"
|
exec go run ./cmd/server "$@"
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func run(logger *slog.Logger) error {
|
||||||
handler := apphttp.NewHandler(pool, workflowService, logger)
|
handler := apphttp.NewHandler(pool, workflowService, logger)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
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,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,28 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- 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:
|
nomadcode-core:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
HTTP_ADDR: :8080
|
HTTP_ADDR: :8080
|
||||||
DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable
|
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_BASE_URL: ""
|
||||||
MATTERMOST_TOKEN: ""
|
MATTERMOST_TOKEN: ""
|
||||||
PLANE_BASE_URL: ""
|
PLANE_BASE_URL: ""
|
||||||
|
|
@ -28,8 +44,11 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ type Config struct {
|
||||||
AppEnv string
|
AppEnv string
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
|
RedisURL string
|
||||||
|
AuthUsername string
|
||||||
|
AuthPassword string
|
||||||
MattermostBaseURL string
|
MattermostBaseURL string
|
||||||
MattermostToken string
|
MattermostToken string
|
||||||
PlaneBaseURL string
|
PlaneBaseURL string
|
||||||
|
|
@ -17,6 +20,9 @@ func Load() Config {
|
||||||
AppEnv: getEnv("APP_ENV", "local"),
|
AppEnv: getEnv("APP_ENV", "local"),
|
||||||
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
|
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
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"),
|
MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"),
|
||||||
MattermostToken: os.Getenv("MATTERMOST_TOKEN"),
|
MattermostToken: os.Getenv("MATTERMOST_TOKEN"),
|
||||||
PlaneBaseURL: os.Getenv("PLANE_BASE_URL"),
|
PlaneBaseURL: os.Getenv("PLANE_BASE_URL"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
"time"
|
"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"
|
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 := chi.NewRouter()
|
||||||
r.Use(chimiddleware.RequestID)
|
r.Use(chimiddleware.RequestID)
|
||||||
r.Use(chimiddleware.RealIP)
|
r.Use(chimiddleware.RealIP)
|
||||||
|
|
@ -16,14 +21,18 @@ func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler {
|
||||||
r.Use(chimiddleware.Recoverer)
|
r.Use(chimiddleware.Recoverer)
|
||||||
|
|
||||||
r.Get("/healthz", handler.Healthz)
|
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.Post("/tasks", handler.CreateTask)
|
||||||
r.Get("/tasks", handler.ListTasks)
|
r.Get("/tasks", handler.ListTasks)
|
||||||
r.Get("/tasks/{id}", handler.GetTask)
|
r.Get("/tasks/{id}", handler.GetTask)
|
||||||
r.Post("/tasks/{id}/enqueue", handler.EnqueueTask)
|
r.Post("/tasks/{id}/enqueue", handler.EnqueueTask)
|
||||||
})
|
})
|
||||||
|
r.Mount("/", protected)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue