mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
d36f27c29f
* fix(server): stop autoCreateWorkspace from swallowing member-add errors (B6, TASK-1932) A failed AddWorkspaceMember after workspace creation used to be silently discarded, leaving a workspace that's completely unreachable (owner_id alone grants no access) and invisible in the console forever. Retry once, then clean up the orphaned workspace and log loudly on continued failure so on-call can act on it. * fix(server): fail fast when cloud mode runs without secure cookies (B7, TASK-1932) SetCloudMode never forced secureCookies on, so PAD_CLOUD=true without PAD_SECURE_COOKIES was an unenforced ops contract: OAuth's __Host-prefixed session cookie is silently invisible to pad's own cookie reader without Secure set, producing a "logged in but appears logged out" failure mode. Add Config.ValidateCloudSecureCookies and check it at server startup, next to the existing PAD_CLOUD_SECRET requirement, so the misconfiguration is a startup error instead of a runtime mystery. * fix(server): align OAuth session TTL with web session TTL (B9, TASK-1932) handleOAuthLogin minted a 30-day session while every other web login used the 7-day webSessionTTL. createAuthSession derives the store session row, session cookie MaxAge, and CSRF cookie MaxAge all from one ttl argument, so the longer OAuth cookie outlived its own server-side session — the browser kept presenting a cookie whose session had already expired, producing silent 401s. Use webSessionTTL for OAuth logins too. * fix(server): narrow the /api/v1/auth/* CSRF exemption to anonymous endpoints (B8, TASK-1932) The CSRF middleware exempted the entire /api/v1/auth/ prefix, which also covered mutating cookie-authenticated endpoints: PATCH /me, oauth-unlink, 2FA setup/verify/disable, delete-account, token create/delete/rotate, CLI- session approve, and logout. Replace the prefix bypass with an exact-path allowlist of the endpoints that are genuinely pre-session (login, register, bootstrap, password reset, verify-email, resend-verification, 2FA login challenge, CLI session create) or authenticate purely via a cloud secret rather than a cookie (oauth-login, oauth-link — never touch the session, so CSRF isn't a meaningful threat model for them and the sidecar has no CSRF cookie to send). Everything else now requires the double-submit token like any other authenticated mutation; the web client already sends it on every non-GET/HEAD request, so no frontend change is needed. * docs(server): pin the deliberate CSRF-cookie legacy-fallback asymmetry (TASK-1932) Codex review (round 1) flagged that SessionAuth falls back from the __Host-pad_session cookie to the legacy unprefixed name, but the CSRF cookie lookup has no equivalent fallback — meaning a browser holding pre-secure-cookies-flip legacy cookies stays authenticated but gets 403'd on B8's newly CSRF-required endpoints until it re-logs-in. This asymmetry is deliberate, not a bug: the session cookie's value is an unguessable secret regardless of which name carries it, but the CSRF cookie's security property depends on the attacker being unable to set the cookie itself — an unprefixed name is settable from a sibling subdomain, which is exactly the hole __Host- exists to close. Restoring "symmetry" here would silently reopen it. Document the reasoning at the cookie lookup so a future maintainer doesn't "fix" it, and add a pinning test that exercises the exact scenario (secureCookies=true, legacy session + CSRF cookies, CSRF-required endpoint) end to end. * fix(server): require CSRF for session-authenticated requests to exempt auth paths (TASK-1932) Codex round 2 found a P1: handleRegister has an admin-session branch (an already-logged-in admin can create a verified account with no invitation code), but /api/v1/auth/register was unconditionally CSRF-exempt by path. A cross-site POST could ride the admin's cookie into that branch with no CSRF token — the same class of hole as the oauth-unlink case B8 already closed, just missed because register's other paths are genuinely anonymous. Fix generically rather than register-specifically: gate the authCSRFExemptPaths exemption on currentUser(r) == nil. SessionAuth runs before CSRFProtect, so a request that resolved to a real session falls through to the normal double-submit check instead of the early exemption, while a genuinely anonymous request keeps it. This also covers any future session-authenticated branch a handler on this list grows, with no handler changes. Bearer/PAT and cloud-secret (oauth-login/oauth-link) callers are unaffected — they have their own unconditional exemptions later in the same function. * fix(server): require validated Bearer/cloud-secret auth for CSRF exemption (TASK-1932) Codex round 3 found that CSRFProtect's Bearer and X-Cloud-Secret exemptions fired on header/marker PRESENCE, not validation. TokenAuth deliberately falls through (rejectInvalidBearer) instead of 401ing invalid Bearers on /api/v1/auth/* paths to support CLI-token recovery, so a cross-site request carrying a victim's real session cookie plus a garbage Bearer header could ride the cookie past CSRF on any newly-CSRF-required endpoint. The same presence-only pattern in the X-Cloud-Secret exemption is concretely exploitable too: handleSetPlan (and similarly-shaped handlers) accept an admin cookie session as an alternative to the secret, so a garbage X-Cloud-Secret plus a stolen admin cookie could set an arbitrary user's plan with no CSRF token at all. Add ctxValidatedSessionBearer (set by TokenAuth only on successful ValidateSession for CLI session-bearer tokens) alongside the existing ctxIsAPIToken, and a combined isValidatedBearerAuth() helper. CSRFProtect now exempts unconditionally only on validated Bearer auth; an unvalidated Bearer header or cloud-secret marker is exempt only when no session was also resolved for the request (currentUser(r) == nil), preserving the CLI-recovery contract (stale token, no cookie -> 401 from auth, not csrf_error) while closing the cookie-riding case. * fix(server): split CSRF auth-exempt allowlist by session sensitivity (TASK-1932) Codex round 2 gated the entire authCSRFExemptPaths allowlist on currentUser(r) == nil to close handleRegister's admin-session branch, but that gate applied to every anonymous endpoint on the list, not just register. CI's E2E suite caught the regression: the harness bootstraps an admin (minting a session cookie) then POSTs /login to re-authenticate, and the ambient cookie stripped /login of its exemption, producing a spurious 403 csrf_error. login/bootstrap/forgot-password/reset-password/local-reset/verify-email/ resend-verification/2fa-login-verify/oauth-login/oauth-link/cli-sessions- create derive their authority entirely from the request body (credentials, a token, a shared secret), never from the ambient cookie, and pad mints the CSRF cookie AT LOGIN — a pre-session endpoint categorically cannot require a token that doesn't exist yet. Split the allowlist: authCSRFUnconditionalExemptPaths (everything above, exempt regardless of cookie) and authCSRFSessionGatedExemptPaths (register only, exempt only when currentUser(r) == nil, since it alone has a session-privileged admin-account-creation branch). The round-2 security property (admin session + register + no CSRF -> still blocked) and round-3's Bearer/ cloud-secret validated-vs-present composite are unaffected.
529 lines
17 KiB
Go
529 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load default config: %v", err)
|
|
}
|
|
if cfg.LoadedFromFile {
|
|
t.Fatal("expected config to start without a config file")
|
|
}
|
|
if cfg.IsConfigured() {
|
|
t.Fatal("expected config without file or overrides to be unconfigured")
|
|
}
|
|
|
|
cfg.Mode = ModeRemote
|
|
cfg.URL = "https://pad.example.com"
|
|
cfg.Host = "127.0.0.1"
|
|
cfg.Port = 7777
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
|
|
reloaded, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
if !reloaded.LoadedFromFile {
|
|
t.Fatal("expected config to load from file after save")
|
|
}
|
|
if !reloaded.IsConfigured() {
|
|
t.Fatal("expected saved config to count as configured")
|
|
}
|
|
if reloaded.Mode != ModeRemote {
|
|
t.Fatalf("expected mode %q, got %q", ModeRemote, reloaded.Mode)
|
|
}
|
|
if reloaded.URL != "https://pad.example.com" {
|
|
t.Fatalf("expected remote URL to round-trip, got %q", reloaded.URL)
|
|
}
|
|
if reloaded.ConfigPath != filepath.Join(home, ".pad", "config.toml") {
|
|
t.Fatalf("unexpected config path %q", reloaded.ConfigPath)
|
|
}
|
|
}
|
|
|
|
func TestLoadWithPadURLMarksConfigConfigured(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_URL", "https://pad.example.com")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config with PAD_URL: %v", err)
|
|
}
|
|
if !cfg.LoadedFromEnv {
|
|
t.Fatal("expected PAD_URL override to mark config as env-loaded")
|
|
}
|
|
if !cfg.IsConfigured() {
|
|
t.Fatal("expected PAD_URL override to count as configured")
|
|
}
|
|
if cfg.Mode != ModeRemote {
|
|
t.Fatalf("expected PAD_URL to imply remote mode, got %q", cfg.Mode)
|
|
}
|
|
if cfg.BaseURL() != "https://pad.example.com" {
|
|
t.Fatalf("unexpected base URL %q", cfg.BaseURL())
|
|
}
|
|
}
|
|
|
|
func TestManagesLocalServerRequiresConfiguredLocalMode(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if cfg.ManagesLocalServer() {
|
|
t.Fatal("expected default config without explicit mode to avoid local server management")
|
|
}
|
|
|
|
cfg.Mode = ModeLocal
|
|
if cfg.ManagesLocalServer() {
|
|
t.Fatal("expected local mode without persisted/env config to avoid local server management")
|
|
}
|
|
|
|
cfg.LoadedFromFile = true
|
|
if !cfg.ManagesLocalServer() {
|
|
t.Fatal("expected configured local mode to manage a local server")
|
|
}
|
|
|
|
cfg.Mode = ModeRemote
|
|
if cfg.ManagesLocalServer() {
|
|
t.Fatal("expected remote mode to avoid local server management")
|
|
}
|
|
|
|
cfg.Mode = ModeCloud
|
|
if cfg.ManagesLocalServer() {
|
|
t.Fatal("expected cloud mode to avoid local server management")
|
|
}
|
|
}
|
|
|
|
func TestValidModeAcceptsKnownModes(t *testing.T) {
|
|
cases := []struct {
|
|
mode string
|
|
want bool
|
|
}{
|
|
{"", true},
|
|
{ModeLocal, true},
|
|
{ModeRemote, true},
|
|
{ModeCloud, true},
|
|
{"docker", false}, // removed in favor of Remote — pre-launch, no back-compat
|
|
{"bogus", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := ValidMode(tc.mode); got != tc.want {
|
|
t.Fatalf("ValidMode(%q) = %v, want %v", tc.mode, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsCloudReportsCloudMode(t *testing.T) {
|
|
cfg := &Config{Mode: ModeCloud}
|
|
if !cfg.IsCloud() {
|
|
t.Fatal("expected IsCloud() to be true when Mode == ModeCloud")
|
|
}
|
|
cfg.Mode = ModeRemote
|
|
if cfg.IsCloud() {
|
|
t.Fatal("expected IsCloud() to be false when Mode == ModeRemote")
|
|
}
|
|
}
|
|
|
|
func TestCloudBaseURLIsCanonicalAppURL(t *testing.T) {
|
|
if CloudBaseURL != "https://app.getpad.dev" {
|
|
t.Fatalf("CloudBaseURL changed unexpectedly: %q — coordinate with pad-cloud and `pad configure` Cloud-mode handler before changing", CloudBaseURL)
|
|
}
|
|
}
|
|
|
|
// TestIsCloudServerOnlyOptsInViaEnv guards the bug codex caught in PR
|
|
// #272: a `pad init`-written `mode = "cloud"` in config.toml must NOT
|
|
// turn the local pad server into a cloud-tenant deployment. Only an
|
|
// explicit env-var opt-in (PAD_CLOUD=true|1 or PAD_MODE=cloud) should
|
|
// flip IsCloudServer().
|
|
func TestIsCloudServerOnlyOptsInViaEnv(t *testing.T) {
|
|
t.Run("config-file mode=cloud does NOT opt into server cloud mode", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
// Defensive: ensure no env vars leak into this case.
|
|
t.Setenv("PAD_MODE", "")
|
|
t.Setenv("PAD_CLOUD", "")
|
|
|
|
cfg := DefaultConfig()
|
|
cfg.Mode = ModeCloud
|
|
cfg.URL = CloudBaseURL
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
reloaded, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if !reloaded.IsCloud() {
|
|
t.Fatal("expected IsCloud() to be true (Mode == ModeCloud)")
|
|
}
|
|
if reloaded.IsCloudServer() {
|
|
t.Fatal("expected IsCloudServer() to be FALSE for a config-file-only mode=cloud — only env-vars must opt into server cloud-tenant mode")
|
|
}
|
|
})
|
|
|
|
t.Run("PAD_CLOUD=true opts into server cloud mode", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_CLOUD", "true")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if !cfg.IsCloud() {
|
|
t.Fatal("PAD_CLOUD=true should set IsCloud() = true")
|
|
}
|
|
if !cfg.IsCloudServer() {
|
|
t.Fatal("PAD_CLOUD=true must opt into server cloud-tenant mode")
|
|
}
|
|
})
|
|
|
|
t.Run("PAD_CLOUD=1 opts into server cloud mode", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_CLOUD", "1")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if !cfg.IsCloudServer() {
|
|
t.Fatal("PAD_CLOUD=1 must opt into server cloud-tenant mode")
|
|
}
|
|
})
|
|
|
|
t.Run("PAD_MODE=cloud opts into server cloud mode", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_MODE", "cloud")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if !cfg.IsCloud() {
|
|
t.Fatal("PAD_MODE=cloud should set IsCloud() = true")
|
|
}
|
|
if !cfg.IsCloudServer() {
|
|
t.Fatal("PAD_MODE=cloud must opt into server cloud-tenant mode")
|
|
}
|
|
})
|
|
|
|
t.Run("PAD_MODE=remote does NOT opt into server cloud mode", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_MODE", "remote")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if cfg.IsCloud() {
|
|
t.Fatal("PAD_MODE=remote should not set IsCloud() = true")
|
|
}
|
|
if cfg.IsCloudServer() {
|
|
t.Fatal("PAD_MODE=remote must not opt into server cloud-tenant mode")
|
|
}
|
|
})
|
|
|
|
t.Run("env-var opt-in overrides a non-cloud file config", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
// Persist a non-cloud config first.
|
|
cfg := DefaultConfig()
|
|
cfg.Mode = ModeRemote
|
|
cfg.URL = "https://pad.example.com"
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
// Then start with PAD_CLOUD=true: the env opt-in must win and
|
|
// flip both signals.
|
|
t.Setenv("PAD_CLOUD", "true")
|
|
reloaded, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if !reloaded.IsCloud() {
|
|
t.Fatal("PAD_CLOUD=true must override file mode=remote in IsCloud()")
|
|
}
|
|
if !reloaded.IsCloudServer() {
|
|
t.Fatal("PAD_CLOUD=true must opt into server cloud-tenant mode regardless of file config")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidateCloudSecureCookies guards B7 (TASK-1932): a server opted into
|
|
// cloud-tenant mode without secure cookies must fail fast at startup rather
|
|
// than silently shipping a __Host-pad_session cookie that pad's own cookie
|
|
// reader can never see (cookie-name mismatch — "logged in but appears
|
|
// logged out").
|
|
func TestValidateCloudSecureCookies(t *testing.T) {
|
|
t.Run("cloud server without secure cookies is rejected", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_CLOUD", "true")
|
|
t.Setenv("PAD_SECURE_COOKIES", "")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if cfg.SecureCookies {
|
|
t.Fatal("test setup invariant broken: expected SecureCookies to default false")
|
|
}
|
|
if err := cfg.ValidateCloudSecureCookies(); err == nil {
|
|
t.Fatal("expected an error for PAD_CLOUD=true without PAD_SECURE_COOKIES")
|
|
}
|
|
})
|
|
|
|
t.Run("cloud server with secure cookies is accepted", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_CLOUD", "true")
|
|
t.Setenv("PAD_SECURE_COOKIES", "true")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if err := cfg.ValidateCloudSecureCookies(); err != nil {
|
|
t.Fatalf("expected no error when secure cookies are enabled, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("non-cloud server without secure cookies is unaffected", func(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_CLOUD", "")
|
|
t.Setenv("PAD_MODE", "")
|
|
t.Setenv("PAD_SECURE_COOKIES", "")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if err := cfg.ValidateCloudSecureCookies(); err != nil {
|
|
t.Fatalf("self-hosted (non-cloud-server) mode must not require secure cookies, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBrowserURLNormalizesUnspecifiedHost(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
host string
|
|
want string
|
|
}{
|
|
{"loopback unchanged", "127.0.0.1", "http://127.0.0.1:7777"},
|
|
{"named host unchanged", "pad.local", "http://pad.local:7777"},
|
|
{"empty host normalized", "", "http://127.0.0.1:7777"},
|
|
{"ipv4 unspecified normalized", "0.0.0.0", "http://127.0.0.1:7777"},
|
|
{"ipv6 unspecified normalized", "::", "http://127.0.0.1:7777"},
|
|
{"ipv6 bracketed unspecified normalized", "[::]", "http://127.0.0.1:7777"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := &Config{Host: tc.host, Port: 7777}
|
|
if got := cfg.BrowserURL(); got != tc.want {
|
|
t.Fatalf("BrowserURL() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBrowserURLPrefersExplicitURL(t *testing.T) {
|
|
cfg := &Config{
|
|
Host: "0.0.0.0", // would normally be normalized
|
|
Port: 7777,
|
|
URL: "https://app.getpad.dev/",
|
|
}
|
|
want := "https://app.getpad.dev"
|
|
if got := cfg.BrowserURL(); got != want {
|
|
t.Fatalf("BrowserURL() = %q, want %q (explicit URL must win and trailing slash trimmed)", got, want)
|
|
}
|
|
}
|
|
|
|
// TestBaseURLIgnoresPublicURL pins the CLI-only contract for BaseURL():
|
|
// the function backs the local `pad` CLI's choice of API endpoint and
|
|
// must NOT be influenced by PublicURL, even when PublicURL is set.
|
|
// Otherwise a developer with a host-level PUBLIC_URL set for unrelated
|
|
// reasons would have their CLI silently route requests to that URL
|
|
// instead of their actual local server (Codex review of BUG-899).
|
|
func TestBaseURLIgnoresPublicURL(t *testing.T) {
|
|
cfg := &Config{
|
|
Host: "127.0.0.1",
|
|
Port: 7777,
|
|
PublicURL: "https://app.example.com",
|
|
}
|
|
const want = "http://127.0.0.1:7777"
|
|
if got := cfg.BaseURL(); got != want {
|
|
t.Fatalf("BaseURL() = %q, want %q (PublicURL must not leak into CLI routing)", got, want)
|
|
}
|
|
}
|
|
|
|
// TestPublicLinkBaseURLPrecedence pins the resolution order on the
|
|
// server-side accessor used to build emailed link targets: URL >
|
|
// PublicURL > constructed http://host:port. This is what fixes BUG-899
|
|
// — Pad Cloud sets PUBLIC_URL on the pad-cloud sidecar and forwards it
|
|
// to the pad service, so emailed links use the public domain instead
|
|
// of the bind address.
|
|
func TestPublicLinkBaseURLPrecedence(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
publicURL string
|
|
host string
|
|
port int
|
|
want string
|
|
}{
|
|
{"URL wins over PublicURL", "https://api.example.com", "https://app.example.com", "0.0.0.0", 7777, "https://api.example.com"},
|
|
{"URL wins with trailing slash trimmed", "https://api.example.com/", "", "0.0.0.0", 7777, "https://api.example.com"},
|
|
{"PublicURL used when URL empty", "", "https://app.example.com", "0.0.0.0", 7777, "https://app.example.com"},
|
|
{"PublicURL trims trailing slash", "", "https://app.example.com/", "0.0.0.0", 7777, "https://app.example.com"},
|
|
{"falls through to host:port when both empty", "", "", "127.0.0.1", 7777, "http://127.0.0.1:7777"},
|
|
{"host:port fallback exposes 0.0.0.0 (BUG-899 repro shape)", "", "", "0.0.0.0", 7777, "http://0.0.0.0:7777"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := &Config{URL: tc.url, PublicURL: tc.publicURL, Host: tc.host, Port: tc.port}
|
|
if got := cfg.PublicLinkBaseURL(); got != tc.want {
|
|
t.Fatalf("PublicLinkBaseURL() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLoadWithPublicURLDoesNotFlipMode is the safety belt against the
|
|
// footgun of treating PUBLIC_URL the same as PAD_URL: PUBLIC_URL is a
|
|
// generic env var name commonly set in unrelated deployment contexts, so
|
|
// reading it must NOT change the CLI's Mode (which would route the local
|
|
// CLI at a remote URL the operator never asked it to use).
|
|
func TestLoadWithPublicURLDoesNotFlipMode(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_DATA_DIR", home)
|
|
t.Setenv("PUBLIC_URL", "https://app.example.com")
|
|
// Explicitly clear PAD_URL in case the runner's environment has it.
|
|
t.Setenv("PAD_URL", "")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config with PUBLIC_URL: %v", err)
|
|
}
|
|
if cfg.PublicURL != "https://app.example.com" {
|
|
t.Fatalf("expected PUBLIC_URL to populate cfg.PublicURL, got %q", cfg.PublicURL)
|
|
}
|
|
if cfg.URL != "" {
|
|
t.Fatalf("PUBLIC_URL must not populate cfg.URL (would flip CLI to remote mode), got %q", cfg.URL)
|
|
}
|
|
if cfg.Mode == ModeRemote {
|
|
t.Fatal("PUBLIC_URL must not flip Mode to Remote — that's PAD_URL's job")
|
|
}
|
|
// Server-side public-link accessor sees PUBLIC_URL...
|
|
if cfg.PublicLinkBaseURL() != "https://app.example.com" {
|
|
t.Fatalf("expected PublicLinkBaseURL to use PUBLIC_URL when PAD_URL is unset, got %q", cfg.PublicLinkBaseURL())
|
|
}
|
|
// ...but the CLI client accessor does not (would otherwise hijack
|
|
// CLI routing on hosts with PUBLIC_URL set for unrelated reasons).
|
|
if cfg.BaseURL() == "https://app.example.com" {
|
|
t.Fatal("BaseURL() must not be influenced by PUBLIC_URL — CLI routing isolation")
|
|
}
|
|
}
|
|
|
|
// TestPublicURLAloneDoesNotMarkConfigured guards against PUBLIC_URL
|
|
// affecting CLI control flow. IsConfigured() gates whether the CLI shows
|
|
// its "not configured / run setup" branch (cmd/pad/configure.go); if a
|
|
// generic PUBLIC_URL on the host marked the config as env-loaded, a
|
|
// developer who's never run `pad init` would get past that gate and the
|
|
// CLI would happily talk to a default-mode endpoint built from PUBLIC_URL.
|
|
// PUBLIC_URL is server-only — it must never participate in IsConfigured().
|
|
func TestPublicURLAloneDoesNotMarkConfigured(t *testing.T) {
|
|
cfg := &Config{PublicURL: "https://app.example.com"}
|
|
if cfg.IsConfigured() {
|
|
t.Fatal("PUBLIC_URL on its own must not make IsConfigured() true — would short-circuit the CLI's not-configured branch on hosts that set the var for unrelated reasons")
|
|
}
|
|
}
|
|
|
|
// TestPublicURLNotPersistedToTOML pins the contract that PUBLIC_URL is a
|
|
// runtime/deployment fact only and never gets written to config.toml.
|
|
// Without this, `pad init` or `pad configure` running on a host with a
|
|
// generic PUBLIC_URL set for unrelated reasons would persist that URL
|
|
// into ~/.pad/config.toml — the value would then outlive the env var
|
|
// (the next pad invocation, even with PUBLIC_URL unset, would still
|
|
// pick it up from the file) and contaminate emailed links indefinitely.
|
|
func TestPublicURLNotPersistedToTOML(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_DATA_DIR", home)
|
|
|
|
cfg := DefaultConfig()
|
|
cfg.ConfigPath = filepath.Join(home, "config.toml")
|
|
cfg.URL = "https://api.example.com"
|
|
cfg.PublicURL = "https://app.example.com" // simulating value loaded from PUBLIC_URL env
|
|
|
|
if err := cfg.Save(); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
raw, err := os.ReadFile(cfg.ConfigPath)
|
|
if err != nil {
|
|
t.Fatalf("read saved config: %v", err)
|
|
}
|
|
if strings.Contains(string(raw), "https://app.example.com") {
|
|
t.Fatalf("config.toml must not persist PUBLIC_URL value:\n%s", raw)
|
|
}
|
|
// Anchor on the TOML key form (key + " =") so adjacent keys with
|
|
// "public_url" as a substring (e.g. mcp_public_url) don't trip the
|
|
// assertion. This test is specifically guarding the PUBLIC_URL env
|
|
// → cfg.PublicURL → file regression — the substring check would
|
|
// otherwise produce false positives as the schema grows.
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "public_url ") || strings.HasPrefix(trimmed, "public_url=") {
|
|
t.Fatalf("config.toml must not contain a public_url key:\n%s", raw)
|
|
}
|
|
}
|
|
// The persisted url= key (PAD_URL/--url path) should still be present.
|
|
if !strings.Contains(string(raw), "https://api.example.com") {
|
|
t.Fatalf("expected url= to round-trip:\n%s", raw)
|
|
}
|
|
}
|
|
|
|
// TestLoadPADURLBeatsPUBLICURL pins the precedence at the env-load layer
|
|
// so an operator running on a host that has PUBLIC_URL set for unrelated
|
|
// reasons can still override it explicitly with PAD_URL.
|
|
func TestLoadPADURLBeatsPUBLICURL(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("PAD_DATA_DIR", home)
|
|
t.Setenv("PAD_URL", "https://api.example.com")
|
|
t.Setenv("PUBLIC_URL", "https://app.example.com")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
if cfg.URL != "https://api.example.com" {
|
|
t.Fatalf("expected PAD_URL to populate cfg.URL, got %q", cfg.URL)
|
|
}
|
|
if cfg.PublicURL != "https://app.example.com" {
|
|
t.Fatalf("expected PUBLIC_URL to still populate cfg.PublicURL even when PAD_URL set, got %q", cfg.PublicURL)
|
|
}
|
|
if cfg.BaseURL() != "https://api.example.com" {
|
|
t.Fatalf("PAD_URL must win in BaseURL(), got %q", cfg.BaseURL())
|
|
}
|
|
if cfg.PublicLinkBaseURL() != "https://api.example.com" {
|
|
t.Fatalf("PAD_URL must also win in PublicLinkBaseURL(), got %q", cfg.PublicLinkBaseURL())
|
|
}
|
|
}
|