mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* fix(cli): make browser-login work from a machine that isn't the server The #923 callback host fix only worked when the CLI and the self-hosted server ran on the same box. In a cross-machine setup — `multica login` from a laptop against a self-hosted server on a NAS — the flow silently wedged on two issues: 1. The callback host was derived from `--app-url`, so the `cli_callback` URL pointed at the server's IP and the browser could never reach the CLI's local listener on the laptop. The OAuth token never came back and subsequent `/api/workspaces` calls 401'd on stale state. 2. `net.Listen("tcp", ...)` on macOS can produce an IPv6-only socket. Browsers and `curl` resolve `localhost`/`127.0.0.1` to IPv4 first and get "connection refused" even when the URL is otherwise correct. Changes: - Derive the callback host from the CLI's own outbound interface by dialing the server (UDP, no packets sent — just asks the kernel which source IP it would use). Falls back to loopback for public app URLs and to the app IP for offline detection. - Add `--callback-host` flag on `login` and `setup self-host` so reverse-proxy / FQDN users can override auto-detection — this is the follow-up @hassaanz asked for on #923. - Pin the callback listener to `tcp4` so macOS never lands on an IPv6-only socket. - `multica setup self-host`: when the user explicitly passes a remote `--server-url` but omits `--app-url`, infer app URL from the server host and warn instead of silently defaulting to `localhost:3000`. Unit tests cover the binding-decision matrix (public, localhost, same- machine LAN, cross-machine LAN, outbound-detect failure, flag override) and the new setup helpers. Reported by @RafeRoberts in #1494 with very clear repro details. * fix(cli): prompt for app_url instead of guessing on remote server_url Per GPT-Boy's review on MUL-1260: deriving app_url as http://<server-host>:3000 breaks for the common api.example.com + app.example.com split and for https-fronted deploys — the setup flow would still open a broken login URL, just slightly later. Replace the guess with an interactive prompt. If the user hits enter (or stdin is unavailable), fail loudly with a clear usage hint instead of proceeding with bad data.
141 lines
4.1 KiB
Go
141 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"net"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// testCmd returns a minimal cobra.Command with the --profile persistent flag
|
|
// registered, matching the rootCmd setup used in production.
|
|
func testCmd() *cobra.Command {
|
|
cmd := &cobra.Command{}
|
|
cmd.PersistentFlags().String("profile", "", "")
|
|
return cmd
|
|
}
|
|
|
|
func TestResolveAppURL(t *testing.T) {
|
|
cmd := testCmd()
|
|
|
|
t.Run("prefers MULTICA_APP_URL", func(t *testing.T) {
|
|
t.Setenv("MULTICA_APP_URL", "http://localhost:14000")
|
|
t.Setenv("FRONTEND_ORIGIN", "http://localhost:13000")
|
|
|
|
if got := resolveAppURL(cmd); got != "http://localhost:14000" {
|
|
t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:14000")
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to FRONTEND_ORIGIN", func(t *testing.T) {
|
|
t.Setenv("MULTICA_APP_URL", "")
|
|
t.Setenv("FRONTEND_ORIGIN", "http://localhost:13026")
|
|
|
|
if got := resolveAppURL(cmd); got != "http://localhost:13026" {
|
|
t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:13026")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveCallbackBinding(t *testing.T) {
|
|
// Fake outbound detector: pretends the CLI has a fixed LAN IP regardless
|
|
// of which server it dials.
|
|
fixed := func(ip string) func(string) net.IP {
|
|
return func(string) net.IP { return net.ParseIP(ip).To4() }
|
|
}
|
|
failing := func(string) net.IP { return nil }
|
|
|
|
cases := []struct {
|
|
name string
|
|
flagHost string
|
|
serverURL string
|
|
appURL string
|
|
detect func(string) net.IP
|
|
wantCallback string
|
|
wantBind string
|
|
}{
|
|
{
|
|
name: "public app URL stays on loopback",
|
|
appURL: "https://multica.ai",
|
|
serverURL: "https://api.multica.ai",
|
|
detect: failing,
|
|
wantCallback: "localhost",
|
|
wantBind: "127.0.0.1",
|
|
},
|
|
{
|
|
name: "localhost app URL stays on loopback",
|
|
appURL: "http://localhost:3000",
|
|
serverURL: "http://localhost:8080",
|
|
detect: failing,
|
|
wantCallback: "localhost",
|
|
wantBind: "127.0.0.1",
|
|
},
|
|
{
|
|
name: "same-machine self-host uses loopback (CLI IP matches app IP)",
|
|
appURL: "http://192.168.0.28:3000",
|
|
serverURL: "http://192.168.0.28:8080",
|
|
detect: fixed("192.168.0.28"),
|
|
wantCallback: "localhost",
|
|
wantBind: "127.0.0.1",
|
|
},
|
|
{
|
|
name: "cross-machine self-host points callback at CLI's LAN IP",
|
|
appURL: "http://192.168.0.28:3000",
|
|
serverURL: "http://192.168.0.28:8080",
|
|
detect: fixed("192.168.0.47"),
|
|
wantCallback: "192.168.0.47",
|
|
wantBind: "0.0.0.0",
|
|
},
|
|
{
|
|
name: "outbound detection failure falls back to app IP",
|
|
appURL: "http://192.168.0.28:3000",
|
|
serverURL: "http://192.168.0.28:8080",
|
|
detect: failing,
|
|
wantCallback: "192.168.0.28",
|
|
wantBind: "0.0.0.0",
|
|
},
|
|
{
|
|
name: "--callback-host flag overrides everything",
|
|
flagHost: "cli.internal.example",
|
|
appURL: "https://multica.ai",
|
|
serverURL: "https://api.multica.ai",
|
|
detect: fixed("10.0.0.5"),
|
|
wantCallback: "cli.internal.example",
|
|
wantBind: "0.0.0.0",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotCallback, gotBind := resolveCallbackBinding(tc.flagHost, tc.serverURL, tc.appURL, tc.detect)
|
|
if gotCallback != tc.wantCallback {
|
|
t.Errorf("callback host = %q, want %q", gotCallback, tc.wantCallback)
|
|
}
|
|
if gotBind != tc.wantBind {
|
|
t.Errorf("bind addr = %q, want %q", gotBind, tc.wantBind)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeAPIBaseURL(t *testing.T) {
|
|
t.Run("converts websocket base URL", func(t *testing.T) {
|
|
if got := normalizeAPIBaseURL("ws://localhost:18106/ws"); got != "http://localhost:18106" {
|
|
t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "http://localhost:18106")
|
|
}
|
|
})
|
|
|
|
t.Run("keeps http base URL", func(t *testing.T) {
|
|
if got := normalizeAPIBaseURL("http://localhost:8080"); got != "http://localhost:8080" {
|
|
t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "http://localhost:8080")
|
|
}
|
|
})
|
|
|
|
t.Run("falls back to raw value for invalid URL", func(t *testing.T) {
|
|
if got := normalizeAPIBaseURL("://bad-url"); got != "://bad-url" {
|
|
t.Fatalf("normalizeAPIBaseURL() = %q, want %q", got, "://bad-url")
|
|
}
|
|
})
|
|
}
|