Files
pad/internal/config/config_test.go
T
xarmian d36f27c29f fix(server): auth-perimeter hardening — B6–B9 from the IDEA-1927 audit (TASK-1932) (#811)
* 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.
2026-07-04 11:33:18 -04:00

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())
}
}