From 98b9becb67eb6a9477d94b713908a16a7d7e0b65 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Tue, 14 Jul 2026 18:34:52 +0800 Subject: [PATCH 1/3] fix: fail closed when region detection is inconclusive --- src/commands/auth/login.ts | 19 ++++---- src/config/detect-region.ts | 12 ++--- test/auth/timeout-fix.test.ts | 12 +++-- test/commands/auth/login.test.ts | 78 +++++++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index a71b3af9..822488b9 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -76,7 +76,7 @@ export default defineCommand({ } if (flags.apiKey) { - await loginWithApiKey(config, flags.apiKey as string); + await loginWithApiKey(config, flags.apiKey as string, flags.region as Region | undefined); return; } @@ -109,7 +109,7 @@ export default defineCommand({ validate: (v) => (v && v.length > 0 ? undefined : 'API key cannot be empty.'), }); if (isCancel(key)) throw new CLIError('Authentication cancelled.', ExitCode.AUTH); - await loginWithApiKey(config, key as string); + await loginWithApiKey(config, key as string, flags.region as Region | undefined); return; } @@ -131,19 +131,22 @@ async function completeOAuthLogin(config: Config, region: Region): Promise await showDashboardAfterLogin(cfg); } -async function loginWithApiKey(config: Config, key: string): Promise { +async function loginWithApiKey( + config: Config, + key: string, + explicitRegion?: Region, +): Promise { if (config.dryRun) { console.log('Would validate and save API key.'); return; } - // Probe both regions and pick the one the key actually authenticates against. - // This doubles as key validation — if neither region accepts it, the key is bad. - const detected = await detectRegion(key); + // An explicit region is authoritative. Otherwise probe both regions and fail + // closed if neither can be confirmed; never persist a guessed fallback. + const detected = explicitRegion ?? await detectRegion(key); const cfg: Config = { ...config, region: detected, baseUrl: REGIONS[detected], apiKey: key }; - // Verify the detection actually authorizes the quota endpoint (defends against - // detectRegion's graceful 'global' fallback when the network is unreachable). + // Verify the selected region actually authorizes the quota endpoint. try { await requestJson(cfg, { url: quotaEndpoint(cfg.baseUrl) }); } catch { diff --git a/src/config/detect-region.ts b/src/config/detect-region.ts index 25d86c70..3c8fa2d8 100644 --- a/src/config/detect-region.ts +++ b/src/config/detect-region.ts @@ -1,5 +1,7 @@ import { REGIONS, type Region } from "./schema"; import { readConfigFile, writeConfigFile } from "./loader"; +import { CLIError } from "../errors/base"; +import { ExitCode } from "../errors/codes"; const QUOTA_PATH = "/v1/token_plan/remains"; @@ -51,13 +53,11 @@ export async function detectRegion(apiKey: string): Promise { const match = results.find((r) => r.ok); if (!match) { process.stderr.write(" failed\n"); - process.stderr.write( - `Warning: API key failed validation against all regions (global, cn).\n` + - ` This usually means the API key is invalid or the network is blocking requests.\n` + - ` Falling back to 'global'. Subsequent requests may fail.\n` + - ` Run 'mmx auth status' to verify your credentials.\n`, + throw new CLIError( + "Could not determine the API key region.", + ExitCode.AUTH, + "No region was changed. Check the key and network, then retry or pass --region global|cn explicitly.", ); - return "global"; } const detected: Region = match.region; process.stderr.write(` ${detected}\n`); diff --git a/test/auth/timeout-fix.test.ts b/test/auth/timeout-fix.test.ts index b26c08ab..64977384 100644 --- a/test/auth/timeout-fix.test.ts +++ b/test/auth/timeout-fix.test.ts @@ -77,7 +77,7 @@ describe('detect-region: probeRegion auth style fallback', () => { } }); - it('falls back to global when key is invalid for all auth styles and regions', async () => { + it('fails closed when key is invalid for all auth styles and regions', async () => { server = createMockServer({ routes: { '/v1/token_plan/remains': () => @@ -93,8 +93,14 @@ describe('detect-region: probeRegion auth style fallback', () => { try { const { detectRegion } = await import('../../src/config/detect-region'); - const region = await detectRegion('bad-key'); - expect(region).toBe('global'); // graceful fallback + try { + await detectRegion('bad-key'); + throw new Error('Expected region detection to fail'); + } catch (error) { + expect(error).toBeInstanceOf(CLIError); + expect((error as CLIError).message).toBe('Could not determine the API key region.'); + expect((error as CLIError).exitCode).toBe(ExitCode.AUTH); + } } finally { (REGIONS as Record).global = origGlobal; (REGIONS as Record).cn = origCn; diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index ac0e53d3..0b93d499 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -1,7 +1,25 @@ -import { describe, it, expect } from 'bun:test'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { default as loginCommand } from '../../../src/commands/auth/login'; +import { REGIONS } from '../../../src/config/schema'; +import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; describe('auth login command', () => { + const originalConfigDir = process.env.MMX_CONFIG_DIR; + let server: MockServer | undefined; + let configDir: string | undefined; + + afterEach(() => { + server?.close(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + if (originalConfigDir === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = originalConfigDir; + server = undefined; + configDir = undefined; + }); + it('has correct name and description', () => { expect(loginCommand.name).toBe('auth login'); expect(loginCommand.description).toContain('Authenticate'); @@ -35,4 +53,62 @@ describe('auth login command', () => { }), ).rejects.toThrow('--api-key is required'); }); + + it('honors an explicit region without running auto-detection', async () => { + let quotaRequests = 0; + server = createMockServer({ + routes: { + '/v1/token_plan/remains': () => { + quotaRequests += 1; + return jsonResponse({ + model_remains: [], + base_resp: { status_code: 0, status_msg: 'ok' }, + }); + }, + }, + }); + + configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-')); + process.env.MMX_CONFIG_DIR = configDir; + + const originalGlobal = REGIONS.global; + const originalCn = REGIONS.cn; + (REGIONS as Record).global = 'http://127.0.0.1:1'; + (REGIONS as Record).cn = server.url; + + try { + await loginCommand.execute( + { + region: 'global', + baseUrl: originalGlobal, + output: 'text', + timeout: 1, + verbose: false, + quiet: true, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, + { + apiKey: 'cn-test-key', + region: 'cn', + quiet: true, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }, + ); + + expect(quotaRequests).toBe(2); + } finally { + (REGIONS as Record).global = originalGlobal; + (REGIONS as Record).cn = originalCn; + } + }); }); From faab965e9a1842dd7de73a459f33f0ee459de3e4 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Tue, 14 Jul 2026 20:21:32 +0800 Subject: [PATCH 2/3] fix: distinguish auth and network validation failures --- src/commands/auth/login.ts | 53 +++++++++- src/config/detect-region.ts | 58 +++++++++-- test/auth/timeout-fix.test.ts | 25 ++++- test/commands/auth/login.test.ts | 165 +++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 16 deletions(-) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 822488b9..33ed9e29 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -20,6 +20,37 @@ interface QuotaApiResponse { model_remains: QuotaModelRemain[]; } +function isNetworkUnavailable(error: unknown): boolean { + if (error instanceof CLIError) { + return error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT; + } + + if (!(error instanceof Error)) return false; + + if (error.name === 'AbortError' || error.name === 'TimeoutError') return true; + + const message = error.message.toLowerCase(); + const code = 'code' in error ? String(error.code).toLowerCase() : ''; + return (error instanceof TypeError && message === 'fetch failed') + || message.includes('failed to fetch') + || message.includes('unable to connect') + || message.includes('connection refused') + || message.includes('econnrefused') + || message.includes('connection reset') + || message.includes('econnreset') + || message.includes('network error') + || message.includes('enotfound') + || message.includes('getaddrinfo') + || message.includes('proxy') + || message.includes('socket') + || message.includes('etimedout') + || code === 'connectionrefused' + || code === 'econnrefused' + || code === 'econnreset' + || code === 'enotfound' + || code === 'etimedout'; +} + async function showQuotaAfterLogin(config: Config): Promise { try { const url = quotaEndpoint(config.baseUrl); @@ -149,11 +180,23 @@ async function loginWithApiKey( // Verify the selected region actually authorizes the quota endpoint. try { await requestJson(cfg, { url: quotaEndpoint(cfg.baseUrl) }); - } catch { - throw new CLIError( - 'API key validation failed.', - ExitCode.AUTH, - 'Check that your key is valid and belongs to a Token Plan.', + } catch (error) { + if (error instanceof CLIError && error.exitCode === ExitCode.AUTH) { + throw new CLIError( + 'API key validation failed.', + ExitCode.AUTH, + 'Check that your key is valid and belongs to a Token Plan.', + ); + } + + if (!explicitRegion) throw error; + + const validationFailure = isNetworkUnavailable(error) + ? `the ${detected} API endpoint is unreachable` + : `the ${detected} API endpoint returned an inconclusive response`; + process.stderr.write( + `Warning: Could not validate the API key because ${validationFailure}.\n` + + `Saving the explicitly selected region "${detected}". Requests may fail until validation succeeds.\n`, ); } diff --git a/src/config/detect-region.ts b/src/config/detect-region.ts index 3c8fa2d8..d6745393 100644 --- a/src/config/detect-region.ts +++ b/src/config/detect-region.ts @@ -5,6 +5,8 @@ import { ExitCode } from "../errors/codes"; const QUOTA_PATH = "/v1/token_plan/remains"; +type ProbeResult = "authorized" | "unauthorized" | "unreachable" | "inconclusive"; + function quotaUrl(region: Region): string { return REGIONS[region] + QUOTA_PATH; } @@ -13,7 +15,7 @@ async function probeRegion( region: Region, apiKey: string, timeoutMs: number, -): Promise { +): Promise { // MiniMax endpoints accept either Bearer or x-api-key auth — try both. // Some API key types only work with one style; trying both prevents false // negatives that would cause the wrong region to be selected, leading to @@ -23,22 +25,41 @@ async function probeRegion( { "x-api-key": apiKey }, ]; + let sawNetworkFailure = false; + let sawInconclusiveResponse = false; + for (const authHeader of authHeaders) { + let res: Response; try { - const res = await fetch(quotaUrl(region), { + res = await fetch(quotaUrl(region), { headers: { ...authHeader, "Content-Type": "application/json" }, signal: AbortSignal.timeout(timeoutMs), }); - if (!res.ok) continue; + } catch { + sawNetworkFailure = true; + continue; + } + + if (res.status === 401 || res.status === 403) continue; + if (!res.ok) { + sawInconclusiveResponse = true; + continue; + } + + try { const data = (await res.json()) as { base_resp?: { status_code?: number }; }; - if (data.base_resp?.status_code === 0) return true; + if (data.base_resp?.status_code === 0) return "authorized"; + sawInconclusiveResponse = true; } catch { - // Try next auth style before giving up on this region + sawInconclusiveResponse = true; } } - return false; + + if (sawInconclusiveResponse) return "inconclusive"; + if (sawNetworkFailure) return "unreachable"; + return "unauthorized"; } export async function detectRegion(apiKey: string): Promise { @@ -47,16 +68,33 @@ export async function detectRegion(apiKey: string): Promise { const results = await Promise.all( regions.map(async (r) => ({ region: r, - ok: await probeRegion(r, apiKey, 5000), + result: await probeRegion(r, apiKey, 5000), })), ); - const match = results.find((r) => r.ok); + const match = results.find((r) => r.result === "authorized"); if (!match) { process.stderr.write(" failed\n"); + + if (results.every((r) => r.result === "unauthorized")) { + throw new CLIError( + "API key was rejected by all regions.", + ExitCode.AUTH, + "No credentials were changed. Check that the key is valid and belongs to a Token Plan.", + ); + } + + if (results.some((r) => r.result === "unreachable")) { + throw new CLIError( + "Could not reach the regional API endpoints.", + ExitCode.NETWORK, + "No region was changed. Check the network or proxy, then retry or pass --region global|cn explicitly.", + ); + } + throw new CLIError( "Could not determine the API key region.", - ExitCode.AUTH, - "No region was changed. Check the key and network, then retry or pass --region global|cn explicitly.", + ExitCode.GENERAL, + "No region was changed because the API returned an inconclusive response. Retry later or pass --region global|cn explicitly.", ); } const detected: Region = match.region; diff --git a/test/auth/timeout-fix.test.ts b/test/auth/timeout-fix.test.ts index 64977384..2e44e6ec 100644 --- a/test/auth/timeout-fix.test.ts +++ b/test/auth/timeout-fix.test.ts @@ -98,7 +98,7 @@ describe('detect-region: probeRegion auth style fallback', () => { throw new Error('Expected region detection to fail'); } catch (error) { expect(error).toBeInstanceOf(CLIError); - expect((error as CLIError).message).toBe('Could not determine the API key region.'); + expect((error as CLIError).message).toBe('API key was rejected by all regions.'); expect((error as CLIError).exitCode).toBe(ExitCode.AUTH); } } finally { @@ -106,6 +106,29 @@ describe('detect-region: probeRegion auth style fallback', () => { (REGIONS as Record).cn = origCn; } }); + + it('reports a network error when no regional endpoint is reachable', async () => { + const { REGIONS } = await import('../../src/config/schema'); + const origGlobal = REGIONS.global; + const origCn = REGIONS.cn; + (REGIONS as Record).global = 'http://127.0.0.1:1'; + (REGIONS as Record).cn = 'http://127.0.0.1:1'; + + try { + const { detectRegion } = await import('../../src/config/detect-region'); + try { + await detectRegion('unverifiable-key'); + throw new Error('Expected region detection to fail'); + } catch (error) { + expect(error).toBeInstanceOf(CLIError); + expect((error as CLIError).message).toBe('Could not reach the regional API endpoints.'); + expect((error as CLIError).exitCode).toBe(ExitCode.NETWORK); + } + } finally { + (REGIONS as Record).global = origGlobal; + (REGIONS as Record).cn = origCn; + } + }); }); // --------------------------------------------------------------------------- diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 0b93d499..9f1a5ca9 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -3,7 +3,10 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { default as loginCommand } from '../../../src/commands/auth/login'; +import { readConfigFile, writeConfigFile } from '../../../src/config/loader'; import { REGIONS } from '../../../src/config/schema'; +import { CLIError } from '../../../src/errors/base'; +import { ExitCode } from '../../../src/errors/codes'; import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; describe('auth login command', () => { @@ -106,6 +109,168 @@ describe('auth login command', () => { ); expect(quotaRequests).toBe(2); + expect(readConfigFile()).toMatchObject({ + api_key: 'cn-test-key', + region: 'cn', + }); + } finally { + (REGIONS as Record).global = originalGlobal; + (REGIONS as Record).cn = originalCn; + } + }); + + it('does not save an explicitly selected region when authentication is rejected', async () => { + server = createMockServer({ + routes: { + '/v1/token_plan/remains': () => jsonResponse({ error: 'unauthorized' }, 401), + }, + }); + + configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-')); + process.env.MMX_CONFIG_DIR = configDir; + await writeConfigFile({ api_key: 'existing-key', region: 'global' }); + + const originalCn = REGIONS.cn; + (REGIONS as Record).cn = server.url; + + try { + try { + await loginCommand.execute( + { + region: 'global', + baseUrl: REGIONS.global, + output: 'text', + timeout: 1, + verbose: false, + quiet: true, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, + { + apiKey: 'rejected-key', + region: 'cn', + quiet: true, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }, + ); + throw new Error('Expected API key validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(CLIError); + expect((error as CLIError).exitCode).toBe(ExitCode.AUTH); + } + + expect(readConfigFile()).toEqual({ api_key: 'existing-key', region: 'global' }); + } finally { + (REGIONS as Record).cn = originalCn; + } + }); + + it('warns and saves an explicit region when its endpoint is unreachable', async () => { + configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-')); + process.env.MMX_CONFIG_DIR = configDir; + await writeConfigFile({ api_key: 'existing-key', region: 'global' }); + + const originalCn = REGIONS.cn; + (REGIONS as Record).cn = 'http://127.0.0.1:1'; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ''; + (process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => { + stderr += String(chunk); + return true; + }; + + try { + await loginCommand.execute( + { + region: 'global', + baseUrl: REGIONS.global, + output: 'text', + timeout: 1, + verbose: false, + quiet: true, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, + { + apiKey: 'explicit-cn-key', + region: 'cn', + quiet: true, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }, + ); + + expect(readConfigFile()).toEqual({ api_key: 'explicit-cn-key', region: 'cn' }); + expect(stderr).toContain('Warning: Could not validate the API key'); + expect(stderr).toContain('Saving the explicitly selected region "cn"'); + } finally { + (process.stderr as NodeJS.WriteStream).write = originalWrite; + (REGIONS as Record).cn = originalCn; + } + }); + + it('keeps existing credentials when auto-detection cannot reach either region', async () => { + configDir = mkdtempSync(join(tmpdir(), 'mmx-region-login-')); + process.env.MMX_CONFIG_DIR = configDir; + await writeConfigFile({ api_key: 'existing-key', region: 'cn' }); + + const originalGlobal = REGIONS.global; + const originalCn = REGIONS.cn; + (REGIONS as Record).global = 'http://127.0.0.1:1'; + (REGIONS as Record).cn = 'http://127.0.0.1:1'; + + try { + try { + await loginCommand.execute( + { + region: 'cn', + baseUrl: originalCn, + output: 'text', + timeout: 1, + verbose: false, + quiet: true, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }, + { + apiKey: 'unverifiable-key', + quiet: true, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }, + ); + throw new Error('Expected region detection to fail'); + } catch (error) { + expect(error).toBeInstanceOf(CLIError); + expect((error as CLIError).exitCode).toBe(ExitCode.NETWORK); + } + + expect(readConfigFile()).toEqual({ api_key: 'existing-key', region: 'cn' }); } finally { (REGIONS as Record).global = originalGlobal; (REGIONS as Record).cn = originalCn; From b4039d8e1784e596841aef10ddf7f87e383389a5 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Tue, 14 Jul 2026 20:21:41 +0800 Subject: [PATCH 3/3] test: isolate config command mocks --- test/commands/config/set.test.ts | 8 +------- test/commands/config/show.test.ts | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/test/commands/config/set.test.ts b/test/commands/config/set.test.ts index 53f085e1..0851ac1e 100644 --- a/test/commands/config/set.test.ts +++ b/test/commands/config/set.test.ts @@ -1,12 +1,6 @@ -import { describe, it, expect, mock } from 'bun:test'; +import { describe, it, expect } from 'bun:test'; import { default as setCommand } from '../../../src/commands/config/set'; -// Mock file I/O -mock.module('../../../src/config/loader', () => ({ - readConfigFile: () => ({}), - writeConfigFile: mock(() => Promise.resolve()), -})); - describe('config set command', () => { it('has correct name', () => { expect(setCommand.name).toBe('config set'); diff --git a/test/commands/config/show.test.ts b/test/commands/config/show.test.ts index e063b7c6..17d70c8b 100644 --- a/test/commands/config/show.test.ts +++ b/test/commands/config/show.test.ts @@ -1,16 +1,14 @@ -import { describe, it, expect, mock } from 'bun:test'; +import { describe, it, expect, spyOn } from 'bun:test'; import { default as showCommand } from '../../../src/commands/config/show'; +import * as configLoader from '../../../src/config/loader'; -// Mock file I/O -mock.module('../../../src/config/loader', () => ({ - readConfigFile: () => ({ - api_key: 'sk-cp-test-key', - default_text_model: 'MiniMax-M2.7-highspeed', - default_speech_model: 'speech-2.8-hd', - default_video_model: 'MiniMax-Hailuo-2.3-6s-768p', - default_music_model: 'music-2.6', - }), -})); +const CONFIG_FILE = { + api_key: 'sk-cp-test-key', + default_text_model: 'MiniMax-M2.7-highspeed', + default_speech_model: 'speech-2.8-hd', + default_video_model: 'MiniMax-Hailuo-2.3-6s-768p', + default_music_model: 'music-2.6', +}; describe('config show command', () => { it('has correct name', () => { @@ -18,6 +16,7 @@ describe('config show command', () => { }); it('shows configuration', async () => { + const readConfigFile = spyOn(configLoader, 'readConfigFile').mockReturnValue(CONFIG_FILE); const config = { apiKey: 'test-key', region: 'global' as const, @@ -54,10 +53,12 @@ describe('config show command', () => { expect(parsed.timeout).toBe(300); } finally { console.log = originalLog; + readConfigFile.mockRestore(); } }); it('includes default models in output', async () => { + const readConfigFile = spyOn(configLoader, 'readConfigFile').mockReturnValue(CONFIG_FILE); const config = { region: 'global' as const, baseUrl: 'https://api.mmx.io', @@ -95,6 +96,7 @@ describe('config show command', () => { expect(parsed.default_music_model).toBe('music-2.6'); } finally { console.log = originalLog; + readConfigFile.mockRestore(); } }); });