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>
200 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|