molecule-core/workspace-server/cmd/server/dotenv_test.go
Hongming Wang 9a223afba1 fix(dotenv,socket): review-driven hardening of .env loader + WS poll
Independent code review surfaced three required fixes and one cheap
optional one. All addressed here.

dotenv parser:
- `export FOO=bar` was parsed as key `"export FOO"` (with embedded
  space) and silently os.Setenv'd, so a developer pasting from a
  direnv `.envrc` would get junk vars. Now strips the prefix.
- Quoted values weren't unwrapped: `FOO="hello world"` produced value
  `"hello world"` with literal quotes. Now strips one matched pair of
  surrounding `"` or `'`. Inside a quoted value `#` is part of the
  value, not a comment marker (matches godotenv convention).
- UTF-8 BOM at file start (Windows editors) would have produced a
  first key like U+FEFF + "FOO". Now stripped via TrimPrefix.

dotenv loader:
- findDotEnv()'s upward walk would happily pick up `~/.env` or a
  sibling-repo `.env` if the binary was run from `~/Documents/other-
  project/`. Real foot-gun on shared dev boxes. Now gated on a
  monorepo sentinel: the candidate directory must contain
  `workspace-server/go.mod`. Falls through to "no .env found" (=
  pre-fix behavior) when the sentinel is absent.

socket fallback poll:
- startFallbackPoll() previously fired only on onclose, so the very
  first connect attempt — when onclose hasn't fired yet because we
  never had a successful onopen — left the canvas with no HTTP poll
  for the duration of the failing handshake (Chrome can hold a
  SYN-SENT WebSocket open ~75s before giving up). Now also called at
  the top of connect(); the timer-already-running guard makes it a
  no-op when one cycle later onclose calls it again.

Test coverage added: export prefix, single+double quoted values, hash
inside quotes preserved, unterminated quote falls back to bare value,
CRLF stripping locked in, BOM stripping, and a sentinel-rejection
regression test that creates a temp .env with no workspace-server
sibling and asserts findDotEnv refuses to load it.

Verified: 985 canvas tests + 30 dotenv subtests + 4 dotenv integration
tests all pass; tsc clean; rebuilt platform from monorepo root with
stripped env still loads .env (49 vars) and /workspaces returns 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:09:18 -07:00

200 lines
7.2 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)"},
// `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)
}
}