fix(cli/login): accept mcn_ Cloud Node PATs alongside mul_ (MUL-2815)

multica login --token rejected anything not starting with mul_, so
users with a Multica Cloud Node PAT (mcn_ prefix) hit
"invalid token format: must start with mul_" even though the server
middleware verifies both kinds.

Replace the inline literal check with validateLoginTokenPrefix(), backed
by a small loginTokenPrefixes list ({mul_, auth.CloudPATPrefix}) so the
accepted set has one source of truth. Add unit-test coverage so adding
a new prefix in future is an obvious one-line edit.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
yushen
2026-05-29 15:39:52 +08:00
co-authored by multica-agent
parent 5aa4fb7487
commit 47e423c4dd
2 changed files with 72 additions and 2 deletions
+22 -2
View File
@@ -17,9 +17,29 @@ import (
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/cli"
)
// loginTokenPrefixes are the token prefixes `multica login --token` accepts.
// The CLI used to hardcode `mul_` only, which made it impossible to log in
// with a Multica Cloud Node PAT (`mcn_`) even though the server happily
// authenticates both kinds. Keep this list in sync with the prefix branches
// in server/internal/middleware/auth.go.
var loginTokenPrefixes = []string{"mul_", auth.CloudPATPrefix}
// validateLoginTokenPrefix returns nil if token starts with one of the
// CLI-recognised PAT prefixes, or an error describing the accepted set.
// Extracted so the prefix list has one obvious test surface.
func validateLoginTokenPrefix(token string) error {
for _, p := range loginTokenPrefixes {
if strings.HasPrefix(token, p) {
return nil
}
}
return fmt.Errorf("invalid token format: must start with %s", strings.Join(loginTokenPrefixes, " or "))
}
var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate multica with Multica",
@@ -346,8 +366,8 @@ func runAuthLoginToken(cmd *cobra.Command, providedToken string) error {
if token == "" {
return fmt.Errorf("token is required")
}
if !strings.HasPrefix(token, "mul_") {
return fmt.Errorf("invalid token format: must start with mul_")
if err := validateLoginTokenPrefix(token); err != nil {
return err
}
serverURL := resolveServerURL(cmd)
+50
View File
@@ -2,6 +2,7 @@ package main
import (
"net"
"strings"
"testing"
"github.com/spf13/cobra"
@@ -244,3 +245,52 @@ func TestNormalizeAPIBaseURL(t *testing.T) {
}
})
}
// 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)
}
}
}