Patch to v1.1.1. check_env could only reason at module granularity, so a var that lives on one struct of a multi-struct module (e.g. EINHERJAR_SERVER_CORS_ORIGINS on web.Config, not web/server.Config) couldn't be flagged dead when the app composes the sibling struct. Adds struct-level selectors to close that gap. internal/tools (check_env): - New `composes` input: exact config structs as module/subpackage/Struct selectors (e.g. web/server/Config, db-postgres/Config, core/logz/Config), alongside or instead of the coarse `modules` list. - not-composed now reasons over the union of composed vars (from modules AND struct selectors), so it surfaces struct-level dead vars. Coarse modules:["web"] keeps the prior behavior. - parseStructSelector helper; errors on malformed or non-existent selectors. Tests: struct-level dead-var catch (CORS flagged with web/server/Config but not with module web), bad-selector errors; existing calls updated to the new arg. Version bumped to v1.1.1 (badge + serverVersion). No dependency changes.
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
|
)
|
|
|
|
// loadRealIndex builds the index from the sibling framework checkout at test
|
|
// time, so the env tools are tested against the framework's actual struct tags.
|
|
// It deliberately does NOT read data/index.json: that file is only an empty
|
|
// placeholder in the repo (the real index is regenerated by cmd/indexer at
|
|
// image-build time). When the sibling modules aren't present — e.g. a CI that
|
|
// checks out only this module — the test skips rather than fails.
|
|
func loadRealIndex(t *testing.T) *index.Index {
|
|
t.Helper()
|
|
idx, err := index.Build(filepath.Join("..", "..", ".."))
|
|
if err != nil || idx == nil || len(idx.Modules) == 0 {
|
|
t.Skip("framework checkout not available; skipping index-derived test")
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func TestRenderEnvExample(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
got := renderEnvExample(idx, "myapp")
|
|
|
|
// Required, no default → uncommented with a runnable dev value.
|
|
mustContain := []string{
|
|
"APP_ENV=local",
|
|
"APP_CORS_ORIGINS=*",
|
|
"EINHERJAR_PG_HOST=localhost",
|
|
"EINHERJAR_PG_USER=postgres",
|
|
"EINHERJAR_PG_PASSWORD=postgres",
|
|
"EINHERJAR_PG_NAME=myapp",
|
|
// logz is composed → its vars (both defaulted) are documented, commented.
|
|
"# EINHERJAR_LOG_LEVEL=INFO",
|
|
"# EINHERJAR_LOG_JSON=false",
|
|
// Has a framework default → commented line documenting it.
|
|
"# EINHERJAR_SERVER_HOST=0.0.0.0",
|
|
"# EINHERJAR_SERVER_PORT=8080",
|
|
"# EINHERJAR_PG_PORT=5432",
|
|
"# EINHERJAR_PG_SSL_MODE=disable",
|
|
}
|
|
for _, s := range mustContain {
|
|
if !strings.Contains(got, s) {
|
|
t.Errorf(".env.example missing line: %q\n---\n%s", s, got)
|
|
}
|
|
}
|
|
|
|
// The historical drift names must never appear.
|
|
mustNotContain := []string{
|
|
"EINHERJAR_PG_DATABASE",
|
|
"EINHERJAR_SERVER_ADDR",
|
|
// CORS lives on web.Config, which the scaffold does not compose — so it
|
|
// must not be emitted (it would be a dead var).
|
|
"EINHERJAR_SERVER_CORS_ORIGINS",
|
|
}
|
|
for _, s := range mustNotContain {
|
|
if strings.Contains(got, s) {
|
|
t.Errorf(".env.example must not contain %q\n---\n%s", s, got)
|
|
}
|
|
}
|
|
|
|
// A required var must never be silently emitted as a commented line.
|
|
for _, line := range strings.Split(got, "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "# EINHERJAR_PG_NAME") {
|
|
t.Errorf("required EINHERJAR_PG_NAME emitted commented: %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvUnknownVar(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
env := "EINHERJAR_PG_DATABASE=x\nEINHERJAR_SERVER_ADDR=:8080\nAPP_ENV=local\n"
|
|
findings, err := checkEnvVars(idx, env, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unknown := map[string]bool{}
|
|
for _, f := range findings {
|
|
if f.Kind == "unknown-var" {
|
|
unknown[f.Var] = true
|
|
}
|
|
}
|
|
for _, want := range []string{"EINHERJAR_PG_DATABASE", "EINHERJAR_SERVER_ADDR"} {
|
|
if !unknown[want] {
|
|
t.Errorf("expected unknown-var finding for %s; got %+v", want, findings)
|
|
}
|
|
}
|
|
// APP_* must never be flagged.
|
|
for _, f := range findings {
|
|
if f.Var == "APP_ENV" {
|
|
t.Errorf("APP_ENV should never be flagged: %+v", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvMissingRequired(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
// Compose db-postgres but set none of its required vars.
|
|
findings, err := checkEnvVars(idx, "APP_ENV=local\n", []string{"db-postgres"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
missing := map[string]bool{}
|
|
for _, f := range findings {
|
|
if f.Kind == "missing-required" {
|
|
missing[f.Var] = true
|
|
}
|
|
}
|
|
for _, want := range []string{"EINHERJAR_PG_HOST", "EINHERJAR_PG_USER", "EINHERJAR_PG_PASSWORD", "EINHERJAR_PG_NAME"} {
|
|
if !missing[want] {
|
|
t.Errorf("expected missing-required for %s; got %+v", want, findings)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvNotComposed(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
// A real MinIO var while only composing db-postgres → dead (not-composed).
|
|
env := "EINHERJAR_MINIO_ENDPOINT=x\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n"
|
|
findings, err := checkEnvVars(idx, env, []string{"db-postgres"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got bool
|
|
for _, f := range findings {
|
|
if f.Kind == "not-composed" && f.Var == "EINHERJAR_MINIO_ENDPOINT" {
|
|
got = true
|
|
}
|
|
}
|
|
if !got {
|
|
t.Errorf("expected not-composed for EINHERJAR_MINIO_ENDPOINT; got %+v", findings)
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvClean(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
// The scaffold's own output, checked against the modules it composes, is clean.
|
|
env := renderEnvExample(idx, "myapp")
|
|
findings, err := checkEnvVars(idx, env, []string{"core", "web", "db-postgres"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range findings {
|
|
if f.Severity == "error" {
|
|
t.Errorf("scaffold .env.example should have no errors, got %+v", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvUnknownModule(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
if _, err := checkEnvVars(idx, "", []string{"nope"}, nil); err == nil {
|
|
t.Error("expected error for unknown module")
|
|
}
|
|
}
|
|
|
|
// TestCheckEnvStructLevel proves the struct-granularity path catches a dead var
|
|
// that module granularity cannot: EINHERJAR_SERVER_CORS_ORIGINS lives on
|
|
// web.Config, so an app composing web/server/Config (not web.Config) never reads
|
|
// it — module "web" would hide that, struct selectors surface it.
|
|
func TestCheckEnvStructLevel(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
env := "EINHERJAR_SERVER_CORS_ORIGINS=*\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n"
|
|
|
|
// struct-level: server/Config has no CORS → CORS flagged as dead.
|
|
structF, err := checkEnvVars(idx, env, nil, []string{"web/server/Config", "db-postgres/Config"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dead := false
|
|
for _, f := range structF {
|
|
if f.Kind == "not-composed" && f.Var == "EINHERJAR_SERVER_CORS_ORIGINS" {
|
|
dead = true
|
|
}
|
|
}
|
|
if !dead {
|
|
t.Errorf("struct-level: expected EINHERJAR_SERVER_CORS_ORIGINS flagged not-composed; got %+v", structF)
|
|
}
|
|
|
|
// module-level ["web"] keeps CORS silent (documents the coarse behavior).
|
|
modF, err := checkEnvVars(idx, env, []string{"web", "db-postgres"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range modF {
|
|
if f.Var == "EINHERJAR_SERVER_CORS_ORIGINS" {
|
|
t.Errorf("module-level should not flag CORS (it lives in module web), got %+v", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckEnvBadSelector(t *testing.T) {
|
|
idx := loadRealIndex(t)
|
|
if _, err := checkEnvVars(idx, "", nil, []string{"web"}); err == nil {
|
|
t.Error("expected error for a one-segment selector")
|
|
}
|
|
if _, err := checkEnvVars(idx, "", nil, []string{"web/server/Nope"}); err == nil {
|
|
t.Error("expected error for a non-existent struct selector")
|
|
}
|
|
}
|