Skip to content

Commit 6fa6dbb

Browse files
authored
Merge pull request #152 from NianJiuZst/test/oauth-flow
test: add unit tests for OAuth device-code login flow
2 parents 6599fea + 45a9660 commit 6fa6dbb

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

test/auth/oauth.test.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2+
3+
// Prevent openBrowser from actually opening a browser during tests
4+
mock.module('child_process', () => ({
5+
execFile: () => {},
6+
spawn: () => {},
7+
}));
8+
9+
// Dynamic import to avoid module-level side effects
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
let deviceCodeLogin: any;
12+
13+
beforeEach(async () => {
14+
const mod = await import('../../src/auth/oauth');
15+
deviceCodeLogin = mod.deviceCodeLogin;
16+
});
17+
18+
// ---------------------------------------------------------------------------
19+
// Helpers
20+
// ---------------------------------------------------------------------------
21+
22+
type FetchMock = (url: string, opts?: RequestInit) => Promise<Response>;
23+
24+
let capturedState: string | null = null;
25+
let capturedCodeVerifier: string | null = null;
26+
27+
/**
28+
* Build a device/code response whose `state` echoes back the value
29+
* extracted from the actual request body — so it always matches.
30+
*/
31+
function deviceCodeResponse(reqBody: URLSearchParams, overrides: Record<string, unknown> = {}): Response {
32+
capturedState = reqBody.get('state');
33+
capturedCodeVerifier = reqBody.get('code_verifier') ?? reqBody.get('code_challenge');
34+
return {
35+
status: 200,
36+
ok: true,
37+
headers: new Headers({ 'Content-Type': 'application/json' }),
38+
json: async () => ({
39+
user_code: 'TEST-CODE',
40+
verification_uri: 'about:blank',
41+
expired_in: Date.now() + 120_000,
42+
interval: 10,
43+
state: capturedState,
44+
...overrides,
45+
}),
46+
} as Response;
47+
}
48+
49+
function tokenResponse(overrides: Record<string, unknown> = {}) {
50+
return {
51+
status: 200,
52+
ok: true,
53+
headers: new Headers({ 'Content-Type': 'application/json' }),
54+
json: async () => ({
55+
status: 'success',
56+
access_token: 'at-abcdef',
57+
refresh_token: 'rt-abcdef',
58+
expired_in: Date.now() + 86_400_000,
59+
...overrides,
60+
}),
61+
} as Response;
62+
}
63+
64+
function errorResponse(status: number, body?: string) {
65+
return {
66+
status,
67+
ok: false,
68+
headers: new Headers({ 'Content-Type': 'application/json' }),
69+
text: async () => body || '{}',
70+
json: async () => JSON.parse(body || '{}'),
71+
} as Response;
72+
}
73+
74+
/**
75+
* Convenience: create a two-phase mock (device/code → token polling).
76+
* The first matching request returns a device-code response; all subsequent
77+
* requests return token responses.
78+
*/
79+
function mockDeviceCodeFlow(
80+
deviceOverrides?: Record<string, unknown>,
81+
tokenOverrides?: Record<string, unknown>,
82+
): FetchMock {
83+
let first = true;
84+
return (_url: string, opts?: RequestInit) => {
85+
if (first) {
86+
first = false;
87+
const body = opts?.body instanceof URLSearchParams
88+
? opts.body
89+
: new URLSearchParams(String(opts?.body ?? ''));
90+
return Promise.resolve(deviceCodeResponse(body, deviceOverrides));
91+
}
92+
return Promise.resolve(tokenResponse(tokenOverrides));
93+
};
94+
}
95+
96+
const originalFetch = globalThis.fetch;
97+
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99+
function setFetch(fn: any): void {
100+
globalThis.fetch = fn as typeof globalThis.fetch;
101+
}
102+
103+
afterEach(() => {
104+
globalThis.fetch = originalFetch;
105+
capturedState = null;
106+
capturedCodeVerifier = null;
107+
});
108+
109+
// ---------------------------------------------------------------------------
110+
// Tests
111+
// ---------------------------------------------------------------------------
112+
113+
describe('deviceCodeLogin', () => {
114+
it('completes successfully when user approves promptly', async () => {
115+
let pollCount = 0;
116+
setFetch(mock((url: string, opts?: RequestInit) => {
117+
if (url.includes('device/code')) {
118+
const body = opts?.body instanceof URLSearchParams
119+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
120+
return Promise.resolve(deviceCodeResponse(body));
121+
}
122+
pollCount++;
123+
return Promise.resolve(tokenResponse());
124+
}));
125+
126+
const result = await deviceCodeLogin('global');
127+
128+
expect(result.access_token).toBe('at-abcdef');
129+
expect(result.refresh_token).toBe('rt-abcdef');
130+
expect(typeof result.expires_at).toBe('string');
131+
expect(result.region).toBe('global');
132+
expect(pollCount).toBeGreaterThanOrEqual(1);
133+
});
134+
135+
it('returns resource_url when provided by the server', async () => {
136+
setFetch(mock((url: string, opts?: RequestInit) => {
137+
if (url.includes('device/code')) {
138+
const body = opts?.body instanceof URLSearchParams
139+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
140+
return Promise.resolve(deviceCodeResponse(body));
141+
}
142+
return Promise.resolve(tokenResponse({ resource_url: 'https://custom.api.com' }));
143+
}));
144+
145+
const result = await deviceCodeLogin('cn');
146+
147+
expect(result.resource_url).toBe('https://custom.api.com');
148+
expect(result.region).toBe('cn');
149+
});
150+
151+
it('throws when device-code request fails with HTTP error', async () => {
152+
setFetch(mock(() => Promise.resolve(errorResponse(500, 'Server Error'))));
153+
154+
await expect(deviceCodeLogin('global')).rejects.toThrow('Failed to start device-code flow');
155+
});
156+
157+
it('throws on state mismatch', async () => {
158+
// Return a state that's guaranteed to differ from the generated one.
159+
// The generated state is ~22 chars of base64url; this one clearly differs.
160+
setFetch(mock((url: string, opts?: RequestInit) => {
161+
if (url.includes('device/code')) {
162+
const body = opts?.body instanceof URLSearchParams
163+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
164+
return Promise.resolve(deviceCodeResponse(body, { state: 'tampered-state' }));
165+
}
166+
return Promise.resolve(tokenResponse());
167+
}));
168+
169+
await expect(deviceCodeLogin('global')).rejects.toThrow('state mismatch');
170+
});
171+
172+
it('polls until success when token returns pending first', async () => {
173+
let pollCount = 0;
174+
setFetch(mock((url: string, opts?: RequestInit) => {
175+
if (url.includes('device/code')) {
176+
const body = opts?.body instanceof URLSearchParams
177+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
178+
return Promise.resolve(deviceCodeResponse(body, { interval: 1 }));
179+
}
180+
pollCount++;
181+
if (pollCount <= 2) return Promise.resolve(tokenResponse({ status: 'pending' }));
182+
return Promise.resolve(tokenResponse());
183+
}));
184+
185+
const result = await deviceCodeLogin('global');
186+
187+
expect(result.access_token).toBe('at-abcdef');
188+
expect(pollCount).toBe(3); // 2 pending + 1 success
189+
});
190+
191+
it('throws when token endpoint returns a failed status', async () => {
192+
setFetch(mock((url: string, opts?: RequestInit) => {
193+
if (url.includes('device/code')) {
194+
const body = opts?.body instanceof URLSearchParams
195+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
196+
return Promise.resolve(deviceCodeResponse(body, { interval: 1 }));
197+
}
198+
return Promise.resolve(tokenResponse({ status: 'rejected' }));
199+
}));
200+
201+
await expect(deviceCodeLogin('global')).rejects.toThrow('authorization failed: rejected');
202+
});
203+
204+
it('throws when token endpoint returns HTTP error', async () => {
205+
setFetch(mock((url: string, opts?: RequestInit) => {
206+
if (url.includes('device/code')) {
207+
const body = opts?.body instanceof URLSearchParams
208+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
209+
return Promise.resolve(deviceCodeResponse(body, { interval: 1 }));
210+
}
211+
return Promise.resolve(errorResponse(403));
212+
}));
213+
214+
await expect(deviceCodeLogin('global')).rejects.toThrow('authorization failed (HTTP 403)');
215+
});
216+
217+
it('throws on timeout when user never approves', async () => {
218+
setFetch(mock((url: string, opts?: RequestInit) => {
219+
if (url.includes('device/code')) {
220+
const body = opts?.body instanceof URLSearchParams
221+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
222+
// expired_in just barely in future → loop exits quickly
223+
return Promise.resolve(deviceCodeResponse(body, { expired_in: Date.now() + 10, interval: 1 }));
224+
}
225+
return Promise.resolve(tokenResponse({ status: 'pending' }));
226+
}));
227+
228+
await expect(deviceCodeLogin('global')).rejects.toThrow('authorization timed out');
229+
});
230+
231+
it('uses correct OAuth host for global region', async () => {
232+
let deviceCodeUrl = '';
233+
setFetch(mock((url: string, opts?: RequestInit) => {
234+
if (url.includes('device/code')) {
235+
deviceCodeUrl = url;
236+
const body = opts?.body instanceof URLSearchParams
237+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
238+
return Promise.resolve(deviceCodeResponse(body));
239+
}
240+
return Promise.resolve(tokenResponse());
241+
}));
242+
243+
await deviceCodeLogin('global');
244+
expect(deviceCodeUrl).toContain('account.minimax.io');
245+
expect(deviceCodeUrl).toContain('/oauth2/device/code');
246+
});
247+
248+
it('uses correct OAuth host for cn region', async () => {
249+
let deviceCodeUrl = '';
250+
setFetch(mock((url: string, opts?: RequestInit) => {
251+
if (url.includes('device/code')) {
252+
deviceCodeUrl = url;
253+
const body = opts?.body instanceof URLSearchParams
254+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
255+
return Promise.resolve(deviceCodeResponse(body));
256+
}
257+
return Promise.resolve(tokenResponse());
258+
}));
259+
260+
await deviceCodeLogin('cn');
261+
expect(deviceCodeUrl).toContain('account.minimaxi.com');
262+
expect(deviceCodeUrl).toContain('/oauth2/device/code');
263+
});
264+
265+
it('sends PKCE parameters in device-code request', async () => {
266+
setFetch(mock((url: string, opts?: RequestInit) => {
267+
if (url.includes('device/code')) {
268+
const body = opts?.body instanceof URLSearchParams
269+
? opts.body : new URLSearchParams(String(opts?.body ?? ''));
270+
return Promise.resolve(deviceCodeResponse(body));
271+
}
272+
return Promise.resolve(tokenResponse());
273+
}));
274+
275+
await deviceCodeLogin('global');
276+
277+
expect(capturedState).toBeDefined();
278+
expect(capturedState!.length).toBeGreaterThan(0);
279+
expect(capturedCodeVerifier).toBeDefined();
280+
expect(capturedCodeVerifier!.length).toBeGreaterThan(0);
281+
});
282+
});

0 commit comments

Comments
 (0)