mirror of
https://github.com/MiniMax-AI/cli.git
synced 2026-09-18 08:48:06 +08:00
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/**
|
|
* Tests for auth timeout bug fixes:
|
|
*
|
|
* Bug 1 (detect-region.ts): Region probe only sent Bearer auth — keys that
|
|
* only work with x-api-key would fail all probes, fall back to 'global',
|
|
* and every subsequent request would time out or 401.
|
|
*
|
|
* Bug 2 (refresh.ts): Token refresh had no timeout — a slow/unreachable auth
|
|
* server caused the CLI to hang indefinitely.
|
|
*
|
|
* Bug 3 (handler.ts): Timeout errors showed a generic "try --timeout" hint
|
|
* with no guidance about wrong-region or auth issues.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach } from 'bun:test';
|
|
import { createMockServer, jsonResponse, type MockServer } from '../helpers/mock-server';
|
|
import { CLIError } from '../../src/errors/base';
|
|
import { ExitCode } from '../../src/errors/codes';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 1 — detect-region probes the correct usage endpoint for each key type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('detect-region: usage endpoint selection', () => {
|
|
let server: MockServer;
|
|
|
|
afterEach(() => server?.close());
|
|
|
|
it('succeeds when endpoint only accepts Bearer token', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/v1/token_plan/remains': (req) => {
|
|
if (req.headers.get('Authorization') === 'Bearer bearer-only-key') {
|
|
return jsonResponse({ base_resp: { status_code: 0 } });
|
|
}
|
|
return jsonResponse({ error: 'unauthorized' }, 401);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Patch REGIONS to point at our mock server
|
|
const { REGIONS } = await import('../../src/config/schema');
|
|
const origGlobal = REGIONS.global;
|
|
(REGIONS as Record<string, string>).global = server.url;
|
|
|
|
try {
|
|
const { detectRegion } = await import('../../src/config/detect-region');
|
|
const region = await detectRegion('bearer-only-key');
|
|
expect(region).toBe('global');
|
|
} finally {
|
|
(REGIONS as Record<string, string>).global = origGlobal;
|
|
}
|
|
});
|
|
|
|
it('uses account/query_balance for sk-api keys', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/account/query_balance': (req) => {
|
|
if (req.headers.get('Authorization') === 'Bearer sk-api-secret-key') {
|
|
return jsonResponse({ base_resp: { status_code: 0 }, available_amount: '1' });
|
|
}
|
|
return jsonResponse({ error: 'unauthorized' }, 401);
|
|
},
|
|
'/v1/token_plan/remains': () => jsonResponse({ error: 'should not be called' }, 500),
|
|
},
|
|
});
|
|
|
|
const { REGIONS } = await import('../../src/config/schema');
|
|
const origGlobal = REGIONS.global;
|
|
(REGIONS as Record<string, string>).global = server.url;
|
|
|
|
try {
|
|
const { detectRegion } = await import('../../src/config/detect-region');
|
|
const region = await detectRegion('sk-api-secret-key');
|
|
expect(region).toBe('global');
|
|
} finally {
|
|
(REGIONS as Record<string, string>).global = origGlobal;
|
|
}
|
|
});
|
|
|
|
it('succeeds when endpoint only accepts x-api-key header', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/v1/token_plan/remains': (req) => {
|
|
if (req.headers.get('x-api-key') === 'xapikey-only-key') {
|
|
return jsonResponse({ base_resp: { status_code: 0 } });
|
|
}
|
|
return jsonResponse({ error: 'unauthorized' }, 401);
|
|
},
|
|
},
|
|
});
|
|
|
|
const { REGIONS } = await import('../../src/config/schema');
|
|
const origGlobal = REGIONS.global;
|
|
(REGIONS as Record<string, string>).global = server.url;
|
|
|
|
try {
|
|
const { detectRegion } = await import('../../src/config/detect-region');
|
|
const region = await detectRegion('xapikey-only-key');
|
|
expect(region).toBe('global');
|
|
} finally {
|
|
(REGIONS as Record<string, string>).global = origGlobal;
|
|
}
|
|
});
|
|
|
|
it('fails closed when key is invalid for all auth styles and regions', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/v1/token_plan/remains': () =>
|
|
jsonResponse({ error: 'unauthorized' }, 401),
|
|
},
|
|
});
|
|
|
|
const { REGIONS } = await import('../../src/config/schema');
|
|
const origGlobal = REGIONS.global;
|
|
const origCn = REGIONS.cn;
|
|
(REGIONS as Record<string, string>).global = server.url;
|
|
(REGIONS as Record<string, string>).cn = server.url;
|
|
|
|
try {
|
|
const { detectRegion } = await import('../../src/config/detect-region');
|
|
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('API key was rejected by all regions.');
|
|
expect((error as CLIError).exitCode).toBe(ExitCode.AUTH);
|
|
}
|
|
} finally {
|
|
(REGIONS as Record<string, string>).global = origGlobal;
|
|
(REGIONS as Record<string, string>).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<string, string>).global = 'http://127.0.0.1:1';
|
|
(REGIONS as Record<string, string>).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<string, string>).global = origGlobal;
|
|
(REGIONS as Record<string, string>).cn = origCn;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 2 — token refresh has a 10-second timeout and clear error messages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('refreshAccessToken: timeout and error handling', () => {
|
|
let server: MockServer;
|
|
|
|
afterEach(() => server?.close());
|
|
|
|
it('throws a CLIError with AUTH exit code when refresh endpoint returns non-ok', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/v1/oauth/token': () => jsonResponse({ error: 'invalid_grant' }, 400),
|
|
},
|
|
});
|
|
|
|
// Temporarily redirect TOKEN_URL to our mock
|
|
const mod = await import('../../src/auth/refresh');
|
|
|
|
// We test the real function against a mock server via a wrapper
|
|
// that overrides the fetch to hit our local server instead.
|
|
const origFetch = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
if (url.includes('oauth2/token') || url.includes('oauth/token')) {
|
|
return origFetch(`${server.url}/v1/oauth/token`, init);
|
|
}
|
|
return origFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
await expect(mod.refreshAccessToken('expired-refresh-token')).rejects.toMatchObject({
|
|
exitCode: ExitCode.AUTH,
|
|
});
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
}
|
|
});
|
|
|
|
it('returns a fresh token when refresh succeeds', async () => {
|
|
server = createMockServer({
|
|
routes: {
|
|
'/v1/oauth/token': () =>
|
|
jsonResponse({
|
|
status: 'success',
|
|
access_token: 'new-access-token',
|
|
refresh_token: 'new-refresh-token',
|
|
expired_in: Date.now() + 3600 * 1000,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const mod = await import('../../src/auth/refresh');
|
|
const origFetch = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
if (url.includes('oauth2/token') || url.includes('oauth/token')) {
|
|
return origFetch(`${server.url}/v1/oauth/token`, init);
|
|
}
|
|
return origFetch(input, init);
|
|
};
|
|
|
|
try {
|
|
const tokens = await mod.refreshAccessToken('valid-refresh-token');
|
|
expect(tokens.access_token).toBe('new-access-token');
|
|
expect(typeof tokens.expires_at).toBe('string');
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
}
|
|
});
|
|
|
|
it('ensureFreshToken returns cached token when not near expiry', async () => {
|
|
const mod = await import('../../src/auth/refresh');
|
|
const token = await mod.ensureFreshToken({
|
|
access_token: 'still-valid',
|
|
refresh_token: 'refresh',
|
|
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1h from now
|
|
token_type: 'Bearer',
|
|
});
|
|
expect(token).toBe('still-valid');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 3 — timeout error message includes auth / region diagnostic hints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('handleError: timeout message includes region/auth hint', () => {
|
|
it('AbortError message contains region override hint', async () => {
|
|
const { handleError } = await import('../../src/errors/handler');
|
|
|
|
const abortErr = new DOMException('The operation was aborted.', 'AbortError');
|
|
|
|
let captured = '';
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
const origExit = process.exit;
|
|
(process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => {
|
|
captured += String(chunk);
|
|
return true;
|
|
};
|
|
(process as unknown as Record<string, unknown>).exit = () => { throw new Error('exit'); };
|
|
|
|
try {
|
|
handleError(abortErr);
|
|
} catch {
|
|
// mocked process.exit throws — expected
|
|
} finally {
|
|
(process.stderr as NodeJS.WriteStream).write = origWrite;
|
|
(process as unknown as Record<string, unknown>).exit = origExit;
|
|
}
|
|
|
|
expect(captured).toContain('mmx auth status');
|
|
expect(captured).toContain('region');
|
|
});
|
|
|
|
it('CLIError with TIMEOUT exit code shows correct hint', () => {
|
|
const err = new CLIError('Request timed out.', ExitCode.TIMEOUT,
|
|
'Try increasing --timeout (e.g. --timeout 60).\n' +
|
|
'If this happens on every request with a valid API key, you may be hitting the wrong region.\n' +
|
|
'Run: mmx auth status — to check your credentials and region.\n' +
|
|
'Run: mmx config set region global (or cn) — to override the region.',
|
|
);
|
|
|
|
expect(err.exitCode).toBe(ExitCode.TIMEOUT);
|
|
expect(err.hint).toContain('mmx auth status');
|
|
expect(err.hint).toContain('mmx config set region');
|
|
});
|
|
});
|