mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
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>
397 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|