Skip to content

Commit 18b3292

Browse files
fix(cli): add token refresh and centralize auth constants (#2210)
* fix(cli): add token refresh support and centralize auth constants Expired OAuth tokens are now automatically refreshed via getValidAccessToken() instead of silently failing. CLI_CLIENT_ID moved to constants.ts to avoid duplication across auth.ts and setup.ts. * refactor(cli): internalize baseUrl and clientId in refreshAccessToken Make refreshAccessToken a private function that resolves getBaseUrl() and CLI_CLIENT_ID internally instead of requiring them as parameters. * test(cli): add unit tests for auth utilities and commands Add comprehensive vitest test suite covering OAuth PKCE flow, token persistence, token refresh, and CLI auth commands (login/logout/whoami). * chore: add changeset for CLI auth improvements * refactor: move CLI auth tests to src/__tests__/ Move auth test files from colocated __tests__ directories to a centralized src/__tests__/ directory, adjusting mock import paths accordingly.
1 parent 800afc3 commit 18b3292

11 files changed

Lines changed: 825 additions & 32 deletions

File tree

.changeset/spicy-parrots-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ctx7": patch
3+
---
4+
5+
Add token refresh support, centralize auth constants, switch whoami to internal API endpoint with teamspace display, and add unit tests for CLI auth utilities and commands

packages/cli/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import eslintPluginPrettier from "eslint-plugin-prettier";
55
export default defineConfig(
66
{
77
// Base ESLint configuration
8-
ignores: ["node_modules/**", "build/**", "dist/**", ".git/**", ".github/**", "tsup.config.ts"],
8+
ignores: ["node_modules/**", "build/**", "dist/**", ".git/**", ".github/**", "tsup.config.ts", "vitest.config.ts"],
99
},
1010
{
1111
files: ["**/*.ts", "**/*.tsx"],

packages/cli/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"lint:check": "eslint src",
1818
"format": "prettier --write src",
1919
"format:check": "prettier --check src",
20-
"clean": "rm -rf dist node_modules"
20+
"clean": "rm -rf dist node_modules",
21+
"test": "vitest run",
22+
"test:watch": "vitest"
2123
},
2224
"dependencies": {
2325
"@inquirer/prompts": "^8.2.0",
@@ -37,7 +39,8 @@
3739
"prettier": "^3.6.2",
3840
"tsup": "^8.5.0",
3941
"typescript": "^5.8.2",
40-
"typescript-eslint": "^8.28.0"
42+
"typescript-eslint": "^8.28.0",
43+
"vitest": "^4.0.13"
4144
},
4245
"keywords": [
4346
"context7",
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { Command } from "commander";
3+
4+
const mockGetValidAccessToken = vi.fn();
5+
const mockClearTokens = vi.fn();
6+
const mockSaveTokens = vi.fn();
7+
const mockGeneratePKCE = vi.fn();
8+
const mockGenerateState = vi.fn();
9+
const mockCreateCallbackServer = vi.fn();
10+
const mockExchangeCodeForTokens = vi.fn();
11+
const mockBuildAuthorizationUrl = vi.fn();
12+
13+
vi.mock("../utils/auth.js", () => ({
14+
getValidAccessToken: (...args: unknown[]) => mockGetValidAccessToken(...args),
15+
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
16+
saveTokens: (...args: unknown[]) => mockSaveTokens(...args),
17+
generatePKCE: (...args: unknown[]) => mockGeneratePKCE(...args),
18+
generateState: (...args: unknown[]) => mockGenerateState(...args),
19+
createCallbackServer: (...args: unknown[]) => mockCreateCallbackServer(...args),
20+
exchangeCodeForTokens: (...args: unknown[]) => mockExchangeCodeForTokens(...args),
21+
buildAuthorizationUrl: (...args: unknown[]) => mockBuildAuthorizationUrl(...args),
22+
}));
23+
24+
vi.mock("../utils/tracking.js", () => ({
25+
trackEvent: vi.fn(),
26+
}));
27+
28+
const mockSpinner = {
29+
start: vi.fn().mockReturnThis(),
30+
stop: vi.fn().mockReturnThis(),
31+
succeed: vi.fn().mockReturnThis(),
32+
fail: vi.fn().mockReturnThis(),
33+
text: "",
34+
};
35+
vi.mock("ora", () => ({ default: () => mockSpinner }));
36+
37+
const mockOpen = vi.fn().mockResolvedValue(undefined);
38+
vi.mock("open", () => ({ default: (...args: unknown[]) => mockOpen(...args) }));
39+
40+
vi.mock("../constants.js", () => ({ CLI_CLIENT_ID: "test-client-id" }));
41+
vi.mock("../utils/api.js", () => ({ getBaseUrl: () => "https://test.context7.com" }));
42+
43+
import { registerAuthCommands, performLogin } from "../commands/auth.js";
44+
import { trackEvent } from "../utils/tracking.js";
45+
46+
let logOutput: string[];
47+
let errorOutput: string[];
48+
let originalExit: typeof process.exit;
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
logOutput = [];
53+
errorOutput = [];
54+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
55+
logOutput.push(args.join(" "));
56+
});
57+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
58+
errorOutput.push(args.join(" "));
59+
});
60+
originalExit = process.exit;
61+
process.exit = vi.fn() as never;
62+
63+
vi.stubGlobal(
64+
"fetch",
65+
vi.fn(() => {
66+
throw new Error("fetch not mocked");
67+
})
68+
);
69+
});
70+
71+
afterEach(() => {
72+
process.exit = originalExit;
73+
vi.unstubAllGlobals();
74+
vi.restoreAllMocks();
75+
});
76+
77+
async function runCommand(...args: string[]): Promise<void> {
78+
const program = new Command();
79+
program.exitOverride(); // throw instead of process.exit on commander errors
80+
registerAuthCommands(program);
81+
await program.parseAsync(["node", "test", ...args]);
82+
}
83+
84+
describe("login command", () => {
85+
test("skips login when valid token exists", async () => {
86+
mockGetValidAccessToken.mockResolvedValue("existing-token");
87+
await runCommand("login");
88+
expect(logOutput.some((l) => l.includes("already logged in"))).toBe(true);
89+
});
90+
91+
test("tracks login event", async () => {
92+
mockGetValidAccessToken.mockResolvedValue("existing-token");
93+
await runCommand("login");
94+
expect(trackEvent).toHaveBeenCalledWith("command", { name: "login" });
95+
});
96+
97+
test("calls process.exit(1) when login fails", async () => {
98+
mockGetValidAccessToken.mockResolvedValue(null);
99+
mockClearTokens.mockReturnValue(false);
100+
// Mock performLogin to fail by making createCallbackServer reject
101+
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
102+
mockGenerateState.mockReturnValue("state");
103+
mockCreateCallbackServer.mockReturnValue({
104+
port: Promise.resolve(52417),
105+
result: Promise.reject(new Error("timeout")),
106+
close: vi.fn(),
107+
});
108+
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
109+
110+
await runCommand("login").catch(() => {});
111+
expect(process.exit).toHaveBeenCalledWith(1);
112+
});
113+
});
114+
115+
describe("logout command", () => {
116+
test("logs success when tokens were cleared", async () => {
117+
mockClearTokens.mockReturnValue(true);
118+
await runCommand("logout");
119+
expect(logOutput.some((l) => l.includes("Logged out successfully"))).toBe(true);
120+
});
121+
122+
test("logs 'not logged in' when no tokens existed", async () => {
123+
mockClearTokens.mockReturnValue(false);
124+
await runCommand("logout");
125+
expect(logOutput.some((l) => l.includes("You are not logged in"))).toBe(true);
126+
});
127+
128+
test("tracks logout event", async () => {
129+
mockClearTokens.mockReturnValue(false);
130+
await runCommand("logout");
131+
expect(trackEvent).toHaveBeenCalledWith("command", { name: "logout" });
132+
});
133+
});
134+
135+
describe("whoami command", () => {
136+
test("shows 'Not logged in' when no valid token", async () => {
137+
mockGetValidAccessToken.mockResolvedValue(null);
138+
await runCommand("whoami");
139+
expect(logOutput.some((l) => l.includes("Not logged in"))).toBe(true);
140+
});
141+
142+
test("fetches and displays user info when logged in", async () => {
143+
mockGetValidAccessToken.mockResolvedValue("valid-token");
144+
vi.stubGlobal(
145+
"fetch",
146+
vi.fn().mockResolvedValue({
147+
ok: true,
148+
json: () =>
149+
Promise.resolve({
150+
success: true,
151+
name: "Test User",
152+
email: "test@example.com",
153+
teamspace: null,
154+
}),
155+
})
156+
);
157+
158+
await runCommand("whoami");
159+
expect(logOutput.some((l) => l.includes("Logged in"))).toBe(true);
160+
expect(logOutput.some((l) => l.includes("Test User"))).toBe(true);
161+
expect(logOutput.some((l) => l.includes("test@example.com"))).toBe(true);
162+
});
163+
164+
test("shows session expired hint when fetch fails", async () => {
165+
mockGetValidAccessToken.mockResolvedValue("valid-token");
166+
vi.stubGlobal(
167+
"fetch",
168+
vi.fn().mockResolvedValue({
169+
ok: false,
170+
json: () => Promise.reject(new Error("fail")),
171+
})
172+
);
173+
174+
await runCommand("whoami");
175+
expect(logOutput.some((l) => l.includes("Session may be expired"))).toBe(true);
176+
});
177+
178+
test("tracks whoami event", async () => {
179+
mockGetValidAccessToken.mockResolvedValue(null);
180+
await runCommand("whoami");
181+
expect(trackEvent).toHaveBeenCalledWith("command", { name: "whoami" });
182+
});
183+
});
184+
185+
describe("performLogin", () => {
186+
test("returns access_token on success", async () => {
187+
const mockClose = vi.fn();
188+
mockGeneratePKCE.mockReturnValue({ codeVerifier: "verifier", codeChallenge: "challenge" });
189+
mockGenerateState.mockReturnValue("state");
190+
mockCreateCallbackServer.mockReturnValue({
191+
port: Promise.resolve(52417),
192+
result: Promise.resolve({ code: "auth-code", state: "state" }),
193+
close: mockClose,
194+
});
195+
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
196+
mockExchangeCodeForTokens.mockResolvedValue({
197+
access_token: "new-token",
198+
token_type: "bearer",
199+
});
200+
201+
const result = await performLogin();
202+
expect(result).toBe("new-token");
203+
expect(mockSaveTokens).toHaveBeenCalled();
204+
expect(mockClose).toHaveBeenCalled();
205+
});
206+
207+
test("opens browser by default", async () => {
208+
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
209+
mockGenerateState.mockReturnValue("s");
210+
mockCreateCallbackServer.mockReturnValue({
211+
port: Promise.resolve(52417),
212+
result: Promise.resolve({ code: "code", state: "s" }),
213+
close: vi.fn(),
214+
});
215+
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
216+
mockExchangeCodeForTokens.mockResolvedValue({
217+
access_token: "tok",
218+
token_type: "bearer",
219+
});
220+
221+
await performLogin(true);
222+
expect(mockOpen).toHaveBeenCalledWith("https://example.com/auth");
223+
});
224+
225+
test("skips browser open when openBrowser=false", async () => {
226+
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
227+
mockGenerateState.mockReturnValue("s");
228+
mockCreateCallbackServer.mockReturnValue({
229+
port: Promise.resolve(52417),
230+
result: Promise.resolve({ code: "code", state: "s" }),
231+
close: vi.fn(),
232+
});
233+
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
234+
mockExchangeCodeForTokens.mockResolvedValue({
235+
access_token: "tok",
236+
token_type: "bearer",
237+
});
238+
239+
await performLogin(false);
240+
expect(mockOpen).not.toHaveBeenCalled();
241+
});
242+
243+
test("returns null on callback failure", async () => {
244+
const mockClose = vi.fn();
245+
mockGeneratePKCE.mockReturnValue({ codeVerifier: "v", codeChallenge: "c" });
246+
mockGenerateState.mockReturnValue("s");
247+
mockCreateCallbackServer.mockReturnValue({
248+
port: Promise.resolve(52417),
249+
result: Promise.reject(new Error("User cancelled")),
250+
close: mockClose,
251+
});
252+
mockBuildAuthorizationUrl.mockReturnValue("https://example.com/auth");
253+
254+
const result = await performLogin();
255+
expect(result).toBeNull();
256+
expect(mockClose).toHaveBeenCalled();
257+
});
258+
});

0 commit comments

Comments
 (0)