Files
krow_backend/go-api/internal/config/example_env_test.go
Suriyakumarvijayanayagam bd9a8f91fc
Some checks failed
CI / test (push) Failing after 4m40s
CI / fixture (push) Failing after 9s
Make the shipped example envs ones that can actually start
The krow-2 deploy failed on the ANTHROPIC_API_KEY guard, which is the guard
doing its job. Checking what an operator hits *after* fixing it turned up two
older faults in the files they are told to copy — both predating the Groq
switch, both fatal at boot.

HTTP_WRITE_TIMEOUT shipped as 30s in .env.example, .env.docker.example and the
compose default, while validateWriteTimeout refuses anything at or under the
deep tier's 2m deadline. `cp .env.docker.example .env && docker compose up`
could not start. Now 180s. krow-2 never saw this because someone had already
overridden it in that environment.

.env.docker.example carried no model block at all, so a production stack built
from it is refused for a missing MODEL_API_KEY. Added, with the Groq defaults
and the reasoning-effort note (most non-reasoning models reject the request
rather than ignoring the key).

Neither was subtle. Both survived because the examples were prose to every test
in this package: the validator and the file documenting it had no mechanical
connection, so tightening one silently invalidated the other. That connection
is now TestShippedExampleEnvActuallyBoots, which parses each example and runs
Load() on it under the APP_ENV the file itself declares — production for the
docker one, development for the root one, each internally consistent. Verified
by mutation: reverting the timeout, removing the key line, and restoring a
claude-* id each fail it with the message an operator would see.

Go does not treat these files as test inputs, so an example-only edit can be
served a stale pass from the test cache. Noted in the test; use -count=1.

Also documented the upgrade path in handover.md, including the one thing
startup validation cannot catch: renaming ANTHROPIC_API_KEY to MODEL_API_KEY
without replacing the value boots fine and 401s on every run.

gofmt clean, go vet clean, 15/15 packages pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-07 12:25:20 +05:30

133 lines
4.7 KiB
Go

package config
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
)
// TestShippedExampleEnvActuallyBoots loads each example env exactly as an
// operator would and asserts the result passes validation.
//
// THIS TEST EXISTS BECAUSE BOTH EXAMPLES SHIPPED A CONFIGURATION THAT COULD NOT
// START. HTTP_WRITE_TIMEOUT was 30s in files an operator is told to copy, while
// validateWriteTimeout refuses anything at or under the deep tier's 2m
// deadline — so `cp .env.docker.example .env && docker compose up` failed at
// boot. Separately, .env.docker.example carried no model block at all, which in
// production is a second refusal for a missing MODEL_API_KEY.
//
// Neither was a subtle bug. Both survived because the examples were prose to
// every test in this package: the validator and the file documenting it had no
// mechanical connection, so tightening one silently invalidated the other.
// That connection is this test.
//
// CAVEAT: `go test` does not treat these files as inputs, so a run that changes
// ONLY an example env can be served a stale pass from the test cache. Verify
// example edits with `-count=1`. `make test` and CI run from a clean cache and
// are not affected.
func TestShippedExampleEnvActuallyBoots(t *testing.T) {
for _, tc := range []struct {
path string
// Values an operator must supply, standing in for the placeholders the
// file ships. Only credentials and hostnames belong here — anything
// else would be this test papering over a broken example.
operatorSupplies map[string]string
}{
{
path: filepath.Join("..", "..", "..", "infrastructure", ".env.docker.example"),
operatorSupplies: map[string]string{"MODEL_API_KEY": "gsk-operator-supplied"},
},
{
path: filepath.Join("..", "..", "..", ".env.example"),
operatorSupplies: map[string]string{"MODEL_API_KEY": "gsk-operator-supplied"},
},
} {
t.Run(filepath.Base(tc.path), func(t *testing.T) {
env, err := parseDotenv(tc.path)
if err != nil {
t.Fatalf("reading %s: %v", tc.path, err)
}
for k, v := range tc.operatorSupplies {
env[k] = v
}
// Each file is validated under the APP_ENV IT DECLARES, not under
// one this test imposes. The two examples describe different
// deployments and each is internally consistent: .env.docker.example
// is production with sslmode=require, .env.example is development
// with sslmode=disable. Forcing production onto the development file
// fails it on a setting that is correct for what it is.
if env["APP_ENV"] == "" {
t.Fatalf("%s declares no APP_ENV; every example must say what it is", tc.path)
}
os.Clearenv()
for k, v := range env {
t.Setenv(k, v)
}
cfg, err := Load()
if err != nil {
t.Fatalf("%s cannot start: %v\n\n"+
"An operator copying this file gets this error, not a running service. "+
"Fix the example, not this test.", tc.path, err)
}
// Load() succeeding is the assertion. These guard the two specific
// regressions above, so a future edit that reintroduces either one
// fails by name rather than as a generic validation error.
if cfg.HTTP.WriteTimeout <= DeepestAgentDeadline {
t.Errorf("HTTP_WRITE_TIMEOUT is %s, which does not exceed the deep tier's %s deadline",
cfg.HTTP.WriteTimeout, DeepestAgentDeadline)
}
for _, m := range []struct{ key, id string }{
{"MODEL_FAST", cfg.Model.Fast},
{"MODEL_BALANCED", cfg.Model.Balanced},
{"MODEL_DEEP", cfg.Model.Deep},
} {
if m.id == "" {
t.Errorf("%s resolved empty", m.key)
}
}
})
}
}
// parseDotenv reads the KEY=value lines an example file ships.
//
// Deliberately simple: it handles what these files actually contain — comments,
// blank lines, trailing `# ...` notes on a value, and optional quotes. It is
// not a general dotenv implementation, and an example needing one would be an
// example too clever for the operator who has to read it.
func parseDotenv(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
env := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
// A trailing comment, but only when it is spaced off the value — a
// bare # inside a password is part of the password.
if i := strings.Index(value, " #"); i >= 0 {
value = value[:i]
}
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
env[key] = value
}
return env, scanner.Err()
}