From ca80268ad4e094cf27373da576821a73efb9e00c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 27 May 2026 17:05:35 +0200 Subject: [PATCH] fix(cloud): fall back to cloud.json token when ENGRAM_CLOUD_TOKEN is unset resolveCloudRuntimeConfig was zeroing cc.Token unconditionally before reading the env var, so any token persisted in cloud.json was silently discarded. Users running `engram sync --cloud` without ENGRAM_CLOUD_TOKEN exported always received a 401 even when cloud.json held a valid token. Remove the blanket zero assignment so the file token acts as a fallback when the env var is absent. ENGRAM_CLOUD_TOKEN still takes precedence when set. Update the test that locked in the old behaviour and add three new tests that assert the fallback and the end-to-end Authorization header. Closes #343 --- cmd/engram/main.go | 7 +- cmd/engram/main_extra_test.go | 11 ++- cmd/engram/sync_cloud_auth_test.go | 121 +++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 cmd/engram/sync_cloud_auth_test.go diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 30e0a9ec..9ba2a5e0 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -446,9 +446,10 @@ func resolveCloudRuntimeConfig(cfg store.Config) (*cloudConfig, error) { if cc == nil { cc = &cloudConfig{} } - // Legacy persisted tokens in cloud.json are intentionally ignored at runtime. - // Runtime auth must come from ENGRAM_CLOUD_TOKEN. - cc.Token = "" + // ENGRAM_CLOUD_TOKEN overrides any token stored in cloud.json. + // When the env var is absent, the persisted token from cloud.json is used + // as a fallback so that `engram sync --cloud` works without requiring the + // env var to be set in every shell session (fix for issue #343). if v := strings.TrimSpace(os.Getenv("ENGRAM_CLOUD_SERVER")); v != "" { cc.ServerURL = v } diff --git a/cmd/engram/main_extra_test.go b/cmd/engram/main_extra_test.go index 64228031..2f3e3475 100644 --- a/cmd/engram/main_extra_test.go +++ b/cmd/engram/main_extra_test.go @@ -1603,9 +1603,12 @@ func TestResolveCloudRuntimeConfigReturnsErrorWhenPersistedConfigUnreadable(t *t } } -func TestResolveCloudRuntimeConfigIgnoresPersistedTokenWithoutEnvOverride(t *testing.T) { +func TestResolveCloudRuntimeConfigUsesPersistedTokenAsFallback(t *testing.T) { + // Issue #343: when ENGRAM_CLOUD_TOKEN is not set, the token stored in + // cloud.json must be used so that `engram sync --cloud` works without + // requiring users to export the env var in every shell session. cfg := testConfig(t) - if err := saveCloudConfig(cfg, &cloudConfig{ServerURL: "https://cloud.example.test", Token: "legacy-token"}); err != nil { + if err := saveCloudConfig(cfg, &cloudConfig{ServerURL: "https://cloud.example.test", Token: "file-token"}); err != nil { t.Fatalf("save cloud config: %v", err) } t.Setenv("ENGRAM_CLOUD_TOKEN", "") @@ -1617,8 +1620,8 @@ func TestResolveCloudRuntimeConfigIgnoresPersistedTokenWithoutEnvOverride(t *tes if runtimeCfg == nil { t.Fatal("expected non-nil cloud runtime config") } - if runtimeCfg.Token != "" { - t.Fatalf("expected persisted legacy token to be ignored, got %q", runtimeCfg.Token) + if runtimeCfg.Token != "file-token" { + t.Fatalf("expected persisted token %q as fallback, got %q", "file-token", runtimeCfg.Token) } if runtimeCfg.ServerURL != "https://cloud.example.test" { t.Fatalf("expected server URL to remain available, got %q", runtimeCfg.ServerURL) diff --git a/cmd/engram/sync_cloud_auth_test.go b/cmd/engram/sync_cloud_auth_test.go new file mode 100644 index 00000000..bcaddf8c --- /dev/null +++ b/cmd/engram/sync_cloud_auth_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Gentleman-Programming/engram/internal/store" +) + +// TestResolveCloudRuntimeConfigFallsBackToFileToken asserts that +// resolveCloudRuntimeConfig uses the token stored in cloud.json when +// ENGRAM_CLOUD_TOKEN is not set in the environment (issue #343). +func TestResolveCloudRuntimeConfigFallsBackToFileToken(t *testing.T) { + cfg := testConfig(t) + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + t.Setenv("ENGRAM_CLOUD_SERVER", "") + + const fileToken = "file-token-from-cloud-json" + if err := saveCloudConfig(cfg, &cloudConfig{ + ServerURL: "https://cloud.example.test", + Token: fileToken, + }); err != nil { + t.Fatalf("save cloud config: %v", err) + } + + cc, err := resolveCloudRuntimeConfig(cfg) + if err != nil { + t.Fatalf("resolveCloudRuntimeConfig: %v", err) + } + if cc.Token != fileToken { + t.Fatalf("expected token %q from cloud.json fallback, got %q (ENGRAM_CLOUD_TOKEN not set)", fileToken, cc.Token) + } +} + +// TestResolveCloudRuntimeConfigEnvTokenTakesPrecedence asserts that when both +// ENGRAM_CLOUD_TOKEN and a token in cloud.json are present, the env var wins. +func TestResolveCloudRuntimeConfigEnvTokenTakesPrecedence(t *testing.T) { + cfg := testConfig(t) + const envToken = "env-token" + const fileToken = "file-token" + t.Setenv("ENGRAM_CLOUD_TOKEN", envToken) + t.Setenv("ENGRAM_CLOUD_SERVER", "") + + if err := saveCloudConfig(cfg, &cloudConfig{ + ServerURL: "https://cloud.example.test", + Token: fileToken, + }); err != nil { + t.Fatalf("save cloud config: %v", err) + } + + cc, err := resolveCloudRuntimeConfig(cfg) + if err != nil { + t.Fatalf("resolveCloudRuntimeConfig: %v", err) + } + if cc.Token != envToken { + t.Fatalf("expected env token %q to take precedence over file token %q, got %q", envToken, fileToken, cc.Token) + } +} + +// TestSyncCloudSendsAuthorizationHeaderFromFileToken is an end-to-end test that +// verifies sync --cloud attaches the Authorization: Bearer header when the token +// is sourced from cloud.json and ENGRAM_CLOUD_TOKEN is not set (issue #343). +func TestSyncCloudSendsAuthorizationHeaderFromFileToken(t *testing.T) { + stubExitWithPanic(t) + stubRuntimeHooks(t) + + const fileToken = "secret-file-token" + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/sync/pull": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":1,"chunks":[]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/sync/push": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := testConfig(t) + + // Persist token in cloud.json; do NOT set ENGRAM_CLOUD_TOKEN. + t.Setenv("ENGRAM_CLOUD_TOKEN", "") + t.Setenv("ENGRAM_CLOUD_SERVER", "") + + if err := saveCloudConfig(cfg, &cloudConfig{ + ServerURL: srv.URL, + Token: fileToken, + }); err != nil { + t.Fatalf("save cloud config: %v", err) + } + + s, err := store.New(cfg) + if err != nil { + t.Fatalf("open store: %v", err) + } + if err := s.EnrollProject("demo"); err != nil { + _ = s.Close() + t.Fatalf("enroll project: %v", err) + } + _ = s.Close() + + withArgs(t, "engram", "sync", "--cloud", "--project", "demo") + _, _, recovered := captureOutputAndRecover(t, func() { cmdSync(cfg) }) + + if _, ok := recovered.(exitCode); ok { + t.Fatal("sync --cloud fataled; expected success with file token") + } + + wantAuth := "Bearer " + fileToken + if !strings.EqualFold(gotAuth, wantAuth) { + t.Fatalf("expected Authorization header %q, got %q (file token not forwarded)", wantAuth, gotAuth) + } +}