diff --git a/docs/plans/2026-04-15-supply-api-env-guard-plan.md b/docs/plans/2026-04-15-supply-api-env-guard-plan.md new file mode 100644 index 00000000..8b787768 --- /dev/null +++ b/docs/plans/2026-04-15-supply-api-env-guard-plan.md @@ -0,0 +1,130 @@ +# Supply API Env Guard Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为 `supply-api/internal/app` 增加统一环境名校验,确保只有 `dev` / `staging` / `prod` 可以进入运行时与 HTTP 装配流程,避免非法 env 静默启动。 + +**Architecture:** 复用 app 层统一的 env 解析 helper,`BuildRuntime` 和 `BuildServer` 共用同一份校验逻辑。默认空值仍回退 `dev`,但非法值必须显式返回错误。 + +**Tech Stack:** Go, Go test + +--- + +### Task 1: 为 BuildRuntime 增加非法环境校验 + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildRuntime_RejectsUnsupportedEnv(t *testing.T) { + _, err := buildRuntimeWithFactory(RuntimeOptions{Env: "qa", ...}, runtimeFactory{...}) + if err == nil { + t.Fatal("expected unsupported env to fail") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntime_RejectsUnsupportedEnv' -v` +Expected: FAIL,因为当前非法 env 仍会继续构建 + +**Step 3: Write minimal implementation** + +```go +func resolveEnv(env string) (string, error) { + ... +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRuntime_RejectsUnsupportedEnv' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): reject unsupported runtime envs" +``` + +### Task 2: 为 BuildServer 复用非法环境校验 + +**Files:** +- Modify: `supply-api/internal/app/bootstrap.go` +- Modify: `supply-api/internal/app/bootstrap_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildServer_RejectsUnsupportedEnv(t *testing.T) { + _, err := BuildServer(BuildServerOptions{Env: "qa", ...}) + if err == nil { + t.Fatal("expected unsupported env to fail") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildServer_RejectsUnsupportedEnv' -v` +Expected: FAIL,因为当前非法 env 会继续走非 dev 分支 + +**Step 3: Write minimal implementation** + +```go +env, err := resolveEnv(opts.Env) +if err != nil { + return nil, err +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildServer_RejectsUnsupportedEnv' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/bootstrap.go supply-api/internal/app/bootstrap_test.go +git commit -m "refactor(supply-api): guard bootstrap env values" +``` + +### Task 3: 验证与收尾 + +**Files:** +- Verify: `supply-api/internal/app/runtime.go` +- Verify: `supply-api/internal/app/bootstrap.go` +- Verify: `supply-api/cmd/supply-api/main.go` + +**Step 1: Run focused tests** + +Run: `cd "supply-api" && go test ./internal/app ./cmd/supply-api ./internal/httpapi` +Expected: PASS + +**Step 2: Run e2e build-tag tests** + +Run: `cd "supply-api" && go test -tags=e2e ./e2e` +Expected: PASS + +**Step 3: Run repo exit verification** + +Run: `bash "scripts/ci/repo_integrity_check.sh"` +Expected: PASS + +**Step 4: Check formatting** + +Run: `git diff --check` +Expected: no output + +**Step 5: Commit** + +```bash +git add docs/plans/2026-04-15-supply-api-env-guard-plan.md supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go supply-api/internal/app/bootstrap.go supply-api/internal/app/bootstrap_test.go +git commit -m "refactor(supply-api): guard unsupported env values" +``` diff --git a/supply-api/internal/app/bootstrap.go b/supply-api/internal/app/bootstrap.go index b6230833..0a4f26ca 100644 --- a/supply-api/internal/app/bootstrap.go +++ b/supply-api/internal/app/bootstrap.go @@ -37,9 +37,9 @@ func BuildServer(opts BuildServerOptions) (*http.Server, error) { return nil, errors.New("logger is required") } - env := strings.ToLower(strings.TrimSpace(opts.Env)) - if env == "" { - env = "dev" + env, err := resolveEnv(opts.Env) + if err != nil { + return nil, err } if env != "dev" && opts.AuthMiddleware == nil { return nil, errors.New("auth middleware is required outside dev") diff --git a/supply-api/internal/app/bootstrap_test.go b/supply-api/internal/app/bootstrap_test.go index b7506277..440e83a3 100644 --- a/supply-api/internal/app/bootstrap_test.go +++ b/supply-api/internal/app/bootstrap_test.go @@ -63,6 +63,26 @@ func TestBuildServer_ProdRequiresAuthMiddleware(t *testing.T) { } } +func TestBuildServer_RejectsUnsupportedEnv(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + + srv, err := BuildServer(BuildServerOptions{ + Env: "qa", + Logger: testLogger{}, + SupplyAPI: supplyAPI, + AlertAPI: alertAPI, + }) + if err == nil { + t.Fatal("expected unsupported env to fail") + } + if srv != nil { + t.Fatal("expected nil server when env is unsupported") + } + if !strings.Contains(err.Error(), "unsupported env") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildServer_RegistersHealthRoute(t *testing.T) { supplyAPI, alertAPI := mustBuildTestAPIs(t) diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index 761b9bc5..88e5fa4e 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -87,7 +87,10 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt factory.newRedisCache = cache.NewRedisCache } - env := normalizeEnv(opts.Env) + env, err := resolveEnv(opts.Env) + if err != nil { + return nil, err + } now := opts.Now if now == nil { now = time.Now @@ -328,12 +331,17 @@ func (r *Runtime) ShutdownTimeout() time.Duration { return r.serverConfig.ShutdownTimeout } -func normalizeEnv(env string) string { +func resolveEnv(env string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(env)) if normalized == "" { - return "dev" + return "dev", nil + } + switch normalized { + case "dev", "staging", "prod": + return normalized, nil + default: + return "", fmt.Errorf("unsupported env %q", env) } - return normalized } func infof(logger logging.Logger, format string, args ...any) { diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index 1e05d202..34d2487d 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -64,6 +64,28 @@ func TestBuildRuntime_ProdRequiresDatabase(t *testing.T) { } } +func TestBuildRuntime_RejectsUnsupportedEnv(t *testing.T) { + _, err := buildRuntimeWithFactory(RuntimeOptions{ + Env: "qa", + Config: testRuntimeConfig(), + Logger: testLogger{}, + InitContext: context.Background(), + }, runtimeFactory{ + newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) { + return nil, errors.New("db down") + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, nil + }, + }) + if err == nil { + t.Fatal("expected unsupported env to fail") + } + if !strings.Contains(err.Error(), "unsupported env") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildRuntime_DevFallsBackToInMemoryDependencies(t *testing.T) { runtime, err := buildRuntimeWithFactory(RuntimeOptions{ Env: "dev",