forked from molecule-ai/molecule-core
The repo's own .env contains lines like
CONFIGS_DIR= # Path to workspace-configs-templates/...
where the value is empty + an inline comment. The pre-fix parser:
1. v = " # Path to ..."
2. TrimLeft → "# Path to ..."
3. Inline-comment loop looked for " #" or "\t#" — neither matches
because the leading whitespace is gone.
4. Returned the comment text as the value.
Result: os.Setenv("CONFIGS_DIR", "# Path to ...") clobbered the auto-
discovery fallback. The TemplatesHandler then opened the comment as
a directory, ReadDir errored silently, and GET /templates returned
[]. Canvas's Templates panel showed "No templates found in
workspace-configs-templates/" even though 8 valid templates existed
on disk.
Fix: strip leading whitespace from the value FIRST, then run a
position-aware comment scan that treats `#` as a comment marker iff
it's at the start of the (trimmed) value or preceded by whitespace.
A bare `#` mid-value (e.g. `KEY=token#fragment`) still survives.
Quoted-value handling moved above the comment scan so
`KEY="value # not"` keeps the `#` as part of the value — pulled the
quote-detection into the same TrimLeft-then-check shape as the bare
path. The unterminated-quote case still falls through to bare-value
handling.
Three regression tests added covering the exact .env line that
broke (`CONFIGS_DIR= # ...`), spaces-only with comment, and tab-
only with comment.
Verified end-to-end: GET /templates now returns all 8 templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.1 KiB
Go
212 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseDotEnvLine(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
k, v string
|
|
ok bool
|
|
comment string
|
|
}{
|
|
{in: "", ok: false, comment: "empty line"},
|
|
{in: " ", ok: false, comment: "whitespace-only"},
|
|
{in: "# top-level comment", ok: false, comment: "full-line comment"},
|
|
{in: " # indented comment", ok: false, comment: "indented full-line comment"},
|
|
{in: "FOO", ok: false, comment: "no equals"},
|
|
{in: "=BAR", ok: false, comment: "missing key"},
|
|
|
|
{in: "FOO=bar", k: "FOO", v: "bar", ok: true, comment: "plain"},
|
|
{in: " FOO=bar", k: "FOO", v: "bar", ok: true, comment: "leading whitespace"},
|
|
{in: "FOO=bar ", k: "FOO", v: "bar", ok: true, comment: "trailing whitespace stripped"},
|
|
{in: "FOO =bar", k: "FOO", v: "bar", ok: true, comment: "whitespace before equals"},
|
|
|
|
{in: "FOO=bar # comment", k: "FOO", v: "bar", ok: true, comment: "inline space-hash comment"},
|
|
{in: "FOO=bar\t# comment", k: "FOO", v: "bar", ok: true, comment: "inline tab-hash comment"},
|
|
{in: "FOO=bar # lots of spaces", k: "FOO", v: "bar", ok: true, comment: "multiple spaces before hash"},
|
|
|
|
{in: "FOO=bar#nocomment", k: "FOO", v: "bar#nocomment", ok: true, comment: "bare hash inside value preserved"},
|
|
{in: "URL=postgres://u:p@h:5432/db?sslmode=disable", k: "URL", v: "postgres://u:p@h:5432/db?sslmode=disable", ok: true, comment: "url with embedded equals"},
|
|
{in: "TOKEN=eyJhbGciOiJIUzI1NiJ9.payload.sig=", k: "TOKEN", v: "eyJhbGciOiJIUzI1NiJ9.payload.sig=", ok: true, comment: "base64 padding preserved"},
|
|
|
|
{in: "FOO=", k: "FOO", v: "", ok: true, comment: "empty value"},
|
|
{in: "ADMIN_TOKEN=", k: "ADMIN_TOKEN", v: "", ok: true, comment: "empty value (production gate sentinel)"},
|
|
|
|
// Regression: the repo's own .env contains lines like
|
|
// `CONFIGS_DIR= # Path to ...` where the value
|
|
// is empty + an inline comment. Pre-fix parser stripped leading
|
|
// whitespace BEFORE detecting the comment, leaving `#` at v[0]
|
|
// with nothing preceding it, so the inline-comment check missed
|
|
// it and the comment text was returned as the value. Server
|
|
// then tried to use the comment as a directory path and template
|
|
// loading silently failed (GET /templates returned []).
|
|
{in: "CONFIGS_DIR= # Path to /var/foo (auto-discovered if empty)", k: "CONFIGS_DIR", v: "", ok: true, comment: "empty value with leading whitespace + inline comment"},
|
|
{in: "FOO= # comment", k: "FOO", v: "", ok: true, comment: "spaces-only value with inline comment"},
|
|
{in: "FOO=\t# comment", k: "FOO", v: "", ok: true, comment: "tab-only value with inline comment"},
|
|
|
|
// `export` prefix: shell-friendly .env files (direnv, .envrc-style)
|
|
// — the prefix must be stripped, NOT folded into the key.
|
|
{in: "export FOO=bar", k: "FOO", v: "bar", ok: true, comment: "export prefix stripped"},
|
|
{in: " export FOO=bar", k: "FOO", v: "bar", ok: true, comment: "leading whitespace + export"},
|
|
{in: "export DATABASE_URL=postgres://u:p@h/db", k: "DATABASE_URL", v: "postgres://u:p@h/db", ok: true, comment: "export with URL value"},
|
|
|
|
// Quoted values: one matched pair of surrounding quotes is
|
|
// stripped; embedded `#` survives because it isn't an inline
|
|
// comment inside a quote.
|
|
{in: `FOO="hello world"`, k: "FOO", v: "hello world", ok: true, comment: "double-quoted value"},
|
|
{in: `FOO='hello world'`, k: "FOO", v: "hello world", ok: true, comment: "single-quoted value"},
|
|
{in: `FOO="value # not a comment"`, k: "FOO", v: "value # not a comment", ok: true, comment: "hash inside quotes is part of value"},
|
|
{in: `FOO= "padded"`, k: "FOO", v: "padded", ok: true, comment: "whitespace before opening quote"},
|
|
{in: `FOO="unterminated`, k: "FOO", v: `"unterminated`, ok: true, comment: "unterminated quote stays as bare value"},
|
|
|
|
// CRLF endings: bufio.Scanner strips \n; \r is left and stripped
|
|
// by the value-side TrimSpace. Locking this in so a future
|
|
// refactor doesn't accidentally feed \r into os.Setenv.
|
|
{in: "FOO=bar\r", k: "FOO", v: "bar", ok: true, comment: "CRLF trailing carriage return stripped"},
|
|
|
|
// UTF-8 BOM at file start: a Windows-edited .env begins with
|
|
// \xEF\xBB\xBF; without explicit stripping the first key would
|
|
// be "\ufeffFOO".
|
|
{in: "\ufeffFOO=bar", k: "FOO", v: "bar", ok: true, comment: "UTF-8 BOM stripped"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.comment, func(t *testing.T) {
|
|
k, v, ok := parseDotEnvLine(tc.in)
|
|
if ok != tc.ok {
|
|
t.Fatalf("ok = %v, want %v (input=%q)", ok, tc.ok, tc.in)
|
|
}
|
|
if !tc.ok {
|
|
return
|
|
}
|
|
if k != tc.k || v != tc.v {
|
|
t.Fatalf("got (%q, %q), want (%q, %q)", k, v, tc.k, tc.v)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// makeFakeMonorepo creates a temp dir that satisfies isMonorepoRoot()
|
|
// (i.e., contains workspace-server/go.mod) plus a .env file with the
|
|
// given body. Returns the dir so the caller can chdir into it.
|
|
func makeFakeMonorepo(t *testing.T, envBody string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
wsDir := filepath.Join(dir, "workspace-server")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(wsDir, "go.mod"), []byte("module fake\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envBody), 0o644); err != nil {
|
|
t.Fatalf("write .env: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestLoadDotEnvIfPresent_PreservesExisting(t *testing.T) {
|
|
dir := makeFakeMonorepo(t, "DOTENV_TEST_NEW=from_file\nDOTENV_TEST_EXISTING=from_file\n")
|
|
|
|
// Pre-set one of the keys — file value must NOT clobber it.
|
|
t.Setenv("DOTENV_TEST_EXISTING", "from_real_env")
|
|
// Ensure the other key starts unset.
|
|
os.Unsetenv("DOTENV_TEST_NEW")
|
|
t.Cleanup(func() { os.Unsetenv("DOTENV_TEST_NEW") })
|
|
|
|
// Run from the temp dir so findDotEnv picks our fixture.
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(prev) })
|
|
|
|
loadDotEnvIfPresent()
|
|
|
|
if got := os.Getenv("DOTENV_TEST_NEW"); got != "from_file" {
|
|
t.Errorf("DOTENV_TEST_NEW = %q, want %q", got, "from_file")
|
|
}
|
|
if got := os.Getenv("DOTENV_TEST_EXISTING"); got != "from_real_env" {
|
|
t.Errorf("existing env clobbered: got %q, want %q", got, "from_real_env")
|
|
}
|
|
}
|
|
|
|
func TestLoadDotEnvIfPresent_NoFile_NoOp(t *testing.T) {
|
|
dir := t.TempDir() // empty — no .env at this level
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(prev) })
|
|
|
|
// Should not panic, log loud errors, or set anything. Best-effort
|
|
// silent miss is the contract.
|
|
loadDotEnvIfPresent()
|
|
}
|
|
|
|
func TestFindDotEnv_WalksUpward(t *testing.T) {
|
|
root := makeFakeMonorepo(t, "X=1\n")
|
|
nested := filepath.Join(root, "a", "b", "c")
|
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(nested); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(prev) })
|
|
|
|
got, ok := findDotEnv()
|
|
if !ok {
|
|
t.Fatal("expected to find .env walking upward")
|
|
}
|
|
want := filepath.Join(root, ".env")
|
|
// macOS resolves /var → /private/var on TempDir, so compare via
|
|
// EvalSymlinks for both sides to dodge that.
|
|
gotR, _ := filepath.EvalSymlinks(got)
|
|
wantR, _ := filepath.EvalSymlinks(want)
|
|
if gotR != wantR {
|
|
t.Errorf("findDotEnv() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFindDotEnv_RejectsUnrelatedDotEnv(t *testing.T) {
|
|
// Simulates a developer running the binary from inside an
|
|
// unrelated project tree that happens to have its own .env (or
|
|
// from $HOME with a personal ~/.env). Without the monorepo
|
|
// sentinel, findDotEnv would happily load it and clobber env
|
|
// with arbitrary values — a real foot-gun this regression test
|
|
// guards against.
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("LEAKY=value\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(prev) })
|
|
|
|
if got, ok := findDotEnv(); ok {
|
|
t.Errorf("findDotEnv() = %q, ok=true; want ok=false (no workspace-server sibling)", got)
|
|
}
|
|
}
|