Files
multica/server/cmd/multica/cmd_auth_test.go
Bohan Jiang b51d1c4dc3 fix(cli): make browser-login work from a machine that isn't the server (#1556)
* 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.
2026-04-23 16:41:29 +08:00

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