Files
multica/server/cmd/multica/cmd_auth_test.go
YYClaw 9f775db16e fix(cli): stop pflag from garbling login --token help output (MUL-4410) (#5253)
Fix garbled `multica login -h` output: the --token line printed a raw NUL and a hijacked value placeholder. Change the NoOptDefVal sentinel to a printable value and drop backticks from the usage string. Add a regression test that renders the flag help through pflag's real path and asserts no control bytes plus the standard --token string[="prompt"] form.

Co-authored-by: YYClaw <197375+yyclaw@users.noreply.github.com>
Co-authored-by: J <j@multica.ai>
2026-07-12 13:27:09 +08:00

397 lines
14 KiB
Go

package main
import (
"net"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestMain(m *testing.M) {
for _, key := range []string{
"MULTICA_AGENT_ID",
"MULTICA_TASK_ID",
"MULTICA_TOKEN",
"MULTICA_DAEMON_PORT",
"MULTICA_WORKSPACE_ID",
"MULTICA_SERVER_URL",
} {
os.Unsetenv(key)
}
os.Exit(m.Run())
}
// 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 TestBrowserLoginInstructionsSSHRemoteHint(t *testing.T) {
const loginURL = "https://multica.ai/login?cli_callback=http%3A%2F%2Flocalhost%3A43689%2Fcallback"
got := browserLoginInstructions(loginURL, "localhost", 43689, true)
if !strings.Contains(got, "ssh -L 43689:127.0.0.1:43689 <user>@<remote-host>") {
t.Fatalf("remote SSH instructions missing tunnel command:\n%s", got)
}
if !strings.Contains(got, loginURL) {
t.Fatalf("instructions missing login URL:\n%s", got)
}
got = browserLoginInstructions(loginURL, "localhost", 43689, false)
if strings.Contains(got, "ssh -L") {
t.Fatalf("local instructions should not include SSH tunnel command:\n%s", got)
}
got = browserLoginInstructions(loginURL, "192.168.1.25", 43689, true)
if strings.Contains(got, "ssh -L") {
t.Fatalf("non-loopback callback should not include SSH tunnel command:\n%s", got)
}
}
func TestCallbackHostFlagValueReadsParentSetupFlag(t *testing.T) {
var got string
setup := &cobra.Command{Use: "setup"}
setup.Flags().String(callbackHostFlag, "", "")
cloud := &cobra.Command{
Use: "cloud",
Run: func(cmd *cobra.Command, args []string) {
got = callbackHostFlagValue(cmd)
},
}
cloud.Flags().String(callbackHostFlag, "", "")
setup.AddCommand(cloud)
setup.SetArgs([]string{"--callback-host", "10.0.0.5", "cloud"})
if err := setup.Execute(); err != nil {
t.Fatalf("execute setup cloud: %v", err)
}
if got != "10.0.0.5" {
t.Fatalf("callback host = %q, want parent flag value", got)
}
}
// TestLoginTokenFlagWiring asserts the production loginCmd flag is registered
// the way #1994 needs it to be: a String flag (not Bool) with a NoOptDefVal
// so `--token` (no value) keeps its legacy prompt-mode behavior. This is the
// load-bearing regression guard — without these asserts a future change that
// reverts the flag to Bool could pass while a synthetic stand-in test happily
// keeps testing string-flag parsing.
func TestLoginTokenFlagWiring(t *testing.T) {
tokenFlag := loginCmd.Flags().Lookup("token")
if tokenFlag == nil {
t.Fatal("loginCmd is missing the --token flag")
}
if got := tokenFlag.Value.Type(); got != "string" {
t.Fatalf("loginCmd --token type = %q, want %q (regressed to bool?)", got, "string")
}
if tokenFlag.NoOptDefVal != tokenPromptSentinel {
t.Fatalf("loginCmd --token NoOptDefVal = %q, want %q (legacy `multica login --token` prompt mode would break)", tokenFlag.NoOptDefVal, tokenPromptSentinel)
}
}
// TestLoginTokenHelpOutputRendersCleanly renders loginCmd's flag help through
// the same pflag path `multica login -h` uses (FlagUsagesWrapped) and locks the
// user-visible help contract that regressed. The original bug had two causes,
// both invisible to the flag-wiring/parsing tests above:
// - The NoOptDefVal sentinel was "\x00prompt". pflag renders NoOptDefVal
// verbatim into the flag column AND uses "\x00" as its own column-alignment
// marker, so the split-at-first-NUL logic mispadded the line and printed a
// raw NUL to the terminal.
// - The usage string wrapped the PAT example in backticks, so pflag's
// UnquoteUsage hijacked it as the flag's value placeholder and stripped a
// backtick pair from the description.
//
// A comment can't prevent either from recurring; only rendering the real output
// and asserting on it can. This is the regression guard for that output.
func TestLoginTokenHelpOutputRendersCleanly(t *testing.T) {
help := loginCmd.Flags().FlagUsages()
// No control byte may reach the terminal. pflag emits only spaces and
// newlines for layout, so any other sub-0x20 rune (notably the NUL the old
// "\x00prompt" sentinel leaked) means the help rendering is corrupted.
for _, r := range help {
if r < 0x20 && r != '\n' && r != '\t' {
t.Fatalf("login help contains control byte %#x; rendered flag usage:\n%q", r, help)
}
}
// The --token line must show the standard optional-value form. This single
// assertion pins both root causes: the placeholder is `string` (backticks
// removed, so UnquoteUsage no longer hijacks the PAT example) and the
// optional value is the printable `prompt` (no NUL-prefixed sentinel).
if want := `--token string[="prompt"]`; !strings.Contains(help, want) {
t.Fatalf("login help missing %q; rendered flag usage:\n%q", want, help)
}
// The description must survive intact — a swallowed backtick pair used to
// truncate it, so assert the tail of the sentence is still present.
if want := "to be prompted interactively."; !strings.Contains(help, want) {
t.Fatalf("login help missing description tail %q; rendered flag usage:\n%q", want, help)
}
}
// TestLoginTokenFlagParsing exercises every documented invocation form
// against a cobra command wired up exactly the same way as the production
// loginCmd, then runs runAuthLogin's flag-resolution logic to confirm the
// right downstream branch is taken: `--token mul_xxx` and `--token=mul_xxx`
// both consume the value (the bug from #1994), `--token` alone falls
// through to the prompt sentinel (preserves the legacy headless form), and
// no flag at all leaves the browser flow untouched.
func TestLoginTokenFlagParsing(t *testing.T) {
type want struct {
changed bool
resolvedToken string // empty == "fall through to prompt"
expectsPrompted bool
}
cases := []struct {
name string
argv []string
want want
}{
{
name: "space-separated value (the form from #1994)",
argv: []string{"--token", "mul_xxx"},
want: want{changed: true, resolvedToken: "mul_xxx"},
},
{
name: "equals-separated value",
argv: []string{"--token=mul_yyy"},
want: want{changed: true, resolvedToken: "mul_yyy"},
},
{
name: "no value falls through to prompt (legacy CLI_INSTALL.md form)",
argv: []string{"--token"},
want: want{changed: true, expectsPrompted: true},
},
{
name: "explicit empty value also falls through to prompt",
argv: []string{"--token="},
want: want{changed: true, expectsPrompted: true},
},
{
name: "no flag at all → browser flow",
argv: []string{},
want: want{changed: false},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "login"}
// Mirror loginCmd's exact flag wiring. If init() in cmd_login.go
// regresses, TestLoginTokenFlagWiring catches that; here we test
// the parsing behavior given the documented wiring.
cmd.Flags().String("token", "", "")
cmd.Flags().Lookup("token").NoOptDefVal = tokenPromptSentinel
if err := cmd.ParseFlags(tc.argv); err != nil {
t.Fatalf("ParseFlags(%v) error: %v", tc.argv, err)
}
if cmd.Flags().Changed("token") != tc.want.changed {
t.Fatalf("Changed(token) = %v, want %v for argv=%v", cmd.Flags().Changed("token"), tc.want.changed, tc.argv)
}
if !tc.want.changed {
return
}
// Replay runAuthLogin's resolution logic so the test fails if
// either the flag wiring OR the space-form recovery breaks.
tokenFlag, _ := cmd.Flags().GetString("token")
positional := cmd.Flags().Args()
if tokenFlag == tokenPromptSentinel && len(positional) == 1 {
tokenFlag = positional[0]
}
if tc.want.expectsPrompted {
if tokenFlag != tokenPromptSentinel && tokenFlag != "" {
t.Fatalf("expected prompt fall-through, got resolved token %q", tokenFlag)
}
} else {
if tokenFlag != tc.want.resolvedToken {
t.Fatalf("resolved token = %q, want %q", tokenFlag, tc.want.resolvedToken)
}
}
})
}
}
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")
}
})
}
// TestValidateLoginTokenPrefix pins the accepted PAT prefix set for
// `multica login --token`. The original implementation hardcoded `mul_`
// only, which rejected legitimate Multica Cloud Node PATs (`mcn_`) at
// the CLI even though the server's middleware would have accepted them.
// If a future change drops `mcn_` from the list (or accidentally
// broadens the set to anything-goes), this test fails.
func TestValidateLoginTokenPrefix(t *testing.T) {
cases := []struct {
name string
token string
wantErr bool
}{
{name: "mul_ PAT", token: "mul_abc123", wantErr: false},
{name: "mcn_ Cloud Node PAT", token: "mcn_abc123", wantErr: false},
{name: "empty token", token: "", wantErr: true},
{name: "no prefix", token: "abc123", wantErr: true},
{name: "wrong prefix mdt_", token: "mdt_abc123", wantErr: true},
{name: "wrong prefix mat_", token: "mat_abc123", wantErr: true},
{name: "case-sensitive: MUL_ rejected", token: "MUL_abc123", wantErr: true},
{name: "leading whitespace not allowed (callers TrimSpace first)", token: " mul_abc", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
err := validateLoginTokenPrefix(tc.token)
if tc.wantErr && err == nil {
t.Fatalf("validateLoginTokenPrefix(%q) = nil, want error", tc.token)
}
if !tc.wantErr && err != nil {
t.Fatalf("validateLoginTokenPrefix(%q) = %v, want nil", tc.token, err)
}
})
}
// The error string is user-facing; make sure it lists every accepted
// prefix so users hitting it can self-serve. Hardcoding the exact
// prefixes here is deliberate — if someone adds a new prefix to
// loginTokenPrefixes they should also update the docs / this test.
err := validateLoginTokenPrefix("nope_xxx")
if err == nil {
t.Fatal("expected error for unknown prefix")
}
for _, p := range []string{"mul_", "mcn_"} {
if !strings.Contains(err.Error(), p) {
t.Errorf("error %q does not mention prefix %q", err.Error(), p)
}
}
}