Files
multica/server/cmd/multica/cmd_setup_test.go
Bohan Jiang 42251b42fc fix(cli): honor MULTICA_SERVER_URL in setup self-host (#3912) (#3938)
* fix(cli): honor MULTICA_SERVER_URL in setup self-host

`multica setup self-host` resolved the backend URL only from the
--server-url flag, falling back to http://localhost:8080 when the flag
was absent. It never consulted MULTICA_SERVER_URL, even though that env
var is documented on the root --server-url flag and in `multica --help`,
and is honored by every other command via resolveServerURL. A self-host
user who set the env var instead of the flag still hit localhost and got
"Server at http://localhost:8080 is not reachable".

Route server-url and app-url through cli.FlagOrEnv so the documented env
vars (MULTICA_SERVER_URL / MULTICA_APP_URL) are honored when the matching
flag is not set, with the flag still taking precedence. userProvided now
reflects flag-or-env, so an env-sourced remote URL still triggers the
explicit app_url prompt. Not platform-specific despite the report.

Fixes GitHub #3912.

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): normalize MULTICA_SERVER_URL in setup self-host

MULTICA_SERVER_URL is documented as a ws:// daemon address
(ws://localhost:8080/ws) and every other command normalizes it via
NormalizeServerBaseURL before use. setup self-host consumed the resolved
value raw and probed <url>/health, so a self-hoster who set the
documented ws:// form would still fail the reachability check.

Run the flag/env value through normalizeAPIBaseURL (ws->http, wss->https,
strip /ws) so the documented form works and the stored server_url stays a
clean http(s) base. Add a normalization test case and a focused test for
the MULTICA_APP_URL env path (review nit).

Co-authored-by: multica-agent <github@multica.ai>

* docs(self-host): note setup self-host honors MULTICA_SERVER_URL / MULTICA_APP_URL

Document that `setup self-host` reads the env vars when the matching flag
is omitted (flag wins), and that MULTICA_SERVER_URL accepts the ws://…/ws
daemon form. Added to en/zh/ja/ko quickstart for parity.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 14:02:06 +08:00

190 lines
6.1 KiB
Go

package main
import (
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// TestPersistSelfHostConfigIfReachable verifies the fix for the
// setup-wipes-token bug: a failed reachability probe must leave the existing
// config (and its auth token) untouched, instead of overwriting it before the
// probe and bailing — which left the user logged out with no recovery.
func TestPersistSelfHostConfigIfReachable(t *testing.T) {
t.Run("unreachable server preserves existing config and token", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
existing := cli.CLIConfig{
ServerURL: "https://api.old.example",
AppURL: "https://old.example",
WorkspaceID: "ws-1",
Token: "mul_existing_token",
}
if err := cli.SaveCLIConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
proceed, err := persistSelfHostConfigIfReachable(
"https://api.new.example", "https://new.example", "",
func(string) bool { return false },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if proceed {
t.Fatalf("proceed: want false for unreachable server")
}
got, err := cli.LoadCLIConfig()
if err != nil {
t.Fatalf("load config: %v", err)
}
if got.Token != "mul_existing_token" {
t.Fatalf("token: want preserved, got %q", got.Token)
}
if got.ServerURL != "https://api.old.example" {
t.Fatalf("server_url: want unchanged, got %q", got.ServerURL)
}
})
t.Run("reachable server writes new self-host config", func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
proceed, err := persistSelfHostConfigIfReachable(
"https://api.new.example", "https://new.example", "",
func(string) bool { return true },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !proceed {
t.Fatalf("proceed: want true for reachable server")
}
got, err := cli.LoadCLIConfig()
if err != nil {
t.Fatalf("load config: %v", err)
}
if got.ServerURL != "https://api.new.example" || got.AppURL != "https://new.example" {
t.Fatalf("config not written: %+v", got)
}
})
}
// TestResolveSelfHostServerURL covers GitHub #3912: `setup self-host` must
// honor MULTICA_SERVER_URL when --server-url is not passed, instead of always
// defaulting to localhost (which left self-hosters stuck on an "unreachable"
// error). The flag still wins over the env var.
func TestResolveSelfHostServerURL(t *testing.T) {
newCmd := func() *cobra.Command {
c := &cobra.Command{}
c.Flags().String("server-url", "", "")
c.Flags().Int("port", 8080, "")
return c
}
t.Run("env var honored when flag absent", func(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "https://api.internal.co")
serverURL, userProvided := resolveSelfHostServerURL(newCmd())
if serverURL != "https://api.internal.co" {
t.Fatalf("server_url: want env value, got %q", serverURL)
}
if !userProvided {
t.Fatalf("userProvided: want true for env-sourced URL")
}
})
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "https://env.example")
cmd := newCmd()
if err := cmd.Flags().Set("server-url", "https://flag.example"); err != nil {
t.Fatalf("set flag: %v", err)
}
serverURL, userProvided := resolveSelfHostServerURL(cmd)
if serverURL != "https://flag.example" {
t.Fatalf("server_url: want flag value, got %q", serverURL)
}
if !userProvided {
t.Fatalf("userProvided: want true for flag-sourced URL")
}
})
t.Run("falls back to localhost with --port when neither set", func(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "")
cmd := newCmd()
if err := cmd.Flags().Set("port", "9090"); err != nil {
t.Fatalf("set flag: %v", err)
}
serverURL, userProvided := resolveSelfHostServerURL(cmd)
if serverURL != "http://localhost:9090" {
t.Fatalf("server_url: want localhost default, got %q", serverURL)
}
if userProvided {
t.Fatalf("userProvided: want false for localhost fallback")
}
})
// MULTICA_SERVER_URL is documented as a ws:// daemon address; the probe and
// stored config need an http(s) base, so the ws/wss + /ws form must be
// normalized just like every other command does.
t.Run("normalizes the documented ws:// daemon form", func(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "wss://api.internal.co/ws")
serverURL, userProvided := resolveSelfHostServerURL(newCmd())
if serverURL != "https://api.internal.co" {
t.Fatalf("server_url: want normalized https base, got %q", serverURL)
}
if !userProvided {
t.Fatalf("userProvided: want true for env-sourced URL")
}
})
}
// TestSelfHostAppURLHonorsEnv pins the app-url half of the GitHub #3912 fix:
// setup self-host resolves --app-url through the same FlagOrEnv path, so
// MULTICA_APP_URL is honored when the flag is absent.
func TestSelfHostAppURLHonorsEnv(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("app-url", "", "")
t.Run("env honored when flag absent", func(t *testing.T) {
t.Setenv("MULTICA_APP_URL", "https://app.internal.co")
if got := cli.FlagOrEnv(cmd, "app-url", "MULTICA_APP_URL", ""); got != "https://app.internal.co" {
t.Fatalf("app_url: want env value, got %q", got)
}
})
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv("MULTICA_APP_URL", "https://env.example")
if err := cmd.Flags().Set("app-url", "https://flag.example"); err != nil {
t.Fatalf("set flag: %v", err)
}
if got := cli.FlagOrEnv(cmd, "app-url", "MULTICA_APP_URL", ""); got != "https://flag.example" {
t.Fatalf("app_url: want flag value, got %q", got)
}
})
}
func TestServerHostIsLocal(t *testing.T) {
cases := []struct {
name string
server string
want bool
}{
{"localhost", "http://localhost:8080", true},
{"127.0.0.1", "http://127.0.0.1:8080", true},
{"IPv6 loopback", "http://[::1]:8080", true},
{"LAN IP", "http://192.168.0.28:8080", false},
{"public FQDN", "https://api.internal.co", false},
{"unparseable", "://bad", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := serverHostIsLocal(tc.server); got != tc.want {
t.Errorf("serverHostIsLocal(%q) = %v, want %v", tc.server, got, tc.want)
}
})
}
}