Skip to content

Commit c6b8108

Browse files
committed
feat(cli): refactor auth flow and add test coverage
1 parent 7e8031b commit c6b8108

19 files changed

+2250
-323
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@codebuff/sdk": "workspace:*",
3636
"@opentui/core": "^0.1.28",
3737
"@opentui/react": "^0.1.28",
38+
"@tanstack/react-query": "^5.62.8",
3839
"commander": "^14.0.1",
3940
"immer": "^10.1.3",
4041
"open": "^10.1.0",

cli/src/__tests__/e2e-cli.test.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { describe, test, expect } from 'bun:test'
22
import { spawn } from 'child_process'
33
import stripAnsi from 'strip-ansi'
44
import path from 'path'
5-
import { isSDKBuilt } from './test-utils'
5+
import { isSDKBuilt, ensureCliTestEnv } from './test-utils'
66

77
const CLI_PATH = path.join(__dirname, '../index.tsx')
88
const TIMEOUT_MS = 10000
99
const sdkBuilt = isSDKBuilt()
1010

11+
ensureCliTestEnv()
12+
1113
function runCLI(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
1214
return new Promise((resolve, reject) => {
1315
const proc = spawn('bun', ['run', CLI_PATH, ...args], {
@@ -86,11 +88,18 @@ describe.skipIf(!sdkBuilt)('CLI End-to-End Tests', () => {
8688
})
8789

8890
let started = false
89-
proc.stdout?.on('data', () => {
90-
started = true
91+
await new Promise<void>((resolve) => {
92+
const timeout = setTimeout(() => {
93+
resolve()
94+
}, 800)
95+
96+
proc.stdout?.once('data', () => {
97+
started = true
98+
clearTimeout(timeout)
99+
resolve()
100+
})
91101
})
92102

93-
await new Promise(resolve => setTimeout(resolve, 1000))
94103
proc.kill('SIGTERM')
95104

96105
expect(started).toBe(true)
@@ -103,11 +112,18 @@ describe.skipIf(!sdkBuilt)('CLI End-to-End Tests', () => {
103112
})
104113

105114
let started = false
106-
proc.stdout?.on('data', () => {
107-
started = true
115+
await new Promise<void>((resolve) => {
116+
const timeout = setTimeout(() => {
117+
resolve()
118+
}, 800)
119+
120+
proc.stdout?.once('data', () => {
121+
started = true
122+
clearTimeout(timeout)
123+
resolve()
124+
})
108125
})
109126

110-
await new Promise(resolve => setTimeout(resolve, 1000))
111127
proc.kill('SIGTERM')
112128

113129
expect(started).toBe(true)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describe, test, expect, mock } from 'bun:test'
2+
3+
import {
4+
generateLoginUrl,
5+
pollLoginStatus,
6+
type LoginUrlResponse,
7+
} from '../../login/login-flow'
8+
9+
import type { Logger } from '@codebuff/common/types/contracts/logger'
10+
11+
const createLogger = (): Logger & Record<string, ReturnType<typeof mock>> => ({
12+
info: mock(() => {}),
13+
error: mock(() => {}),
14+
warn: mock(() => {}),
15+
debug: mock(() => {}),
16+
})
17+
18+
describe('First-Time Login Flow (helpers)', () => {
19+
test('generateLoginUrl posts fingerprint id and returns payload', async () => {
20+
const logger = createLogger()
21+
const responsePayload: LoginUrlResponse = {
22+
loginUrl: 'https://cli.test/login?code=abc123',
23+
fingerprintHash: 'hash-123',
24+
expiresAt: '2025-12-31T23:59:59Z',
25+
}
26+
27+
const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
28+
expect(typeof input).toBe('string')
29+
expect(String(input)).toBe('https://cli.test/api/auth/cli/code')
30+
expect(init?.method).toBe('POST')
31+
expect(init?.headers).toEqual({ 'Content-Type': 'application/json' })
32+
expect(init?.body).toBe(JSON.stringify({ fingerprintId: 'finger-001' }))
33+
return new Response(JSON.stringify(responsePayload), { status: 200 })
34+
})
35+
36+
const result = await generateLoginUrl(
37+
{ fetch: fetchMock as any, logger },
38+
{ baseUrl: 'https://cli.test', fingerprintId: 'finger-001' },
39+
)
40+
41+
expect(result).toEqual(responsePayload)
42+
expect(fetchMock.mock.calls.length).toBe(1)
43+
})
44+
45+
test('pollLoginStatus resolves with user after handling transient 401 responses', async () => {
46+
const logger = createLogger()
47+
const responses: Array<Response> = [
48+
new Response(null, { status: 401 }),
49+
new Response(null, { status: 401 }),
50+
new Response(
51+
JSON.stringify({
52+
user: {
53+
id: 'new-user-123',
54+
name: 'New User',
55+
email: 'new@codebuff.dev',
56+
authToken: 'token-123',
57+
},
58+
}),
59+
{ status: 200 },
60+
),
61+
]
62+
let callCount = 0
63+
64+
const fetchMock = mock(async (input: RequestInfo) => {
65+
const url = new URL(String(input))
66+
expect(url.searchParams.get('fingerprintId')).toBe('finger-abc')
67+
expect(url.searchParams.get('fingerprintHash')).toBe('hash-xyz')
68+
expect(url.searchParams.get('expiresAt')).toBe('2030-01-02T03:04:05Z')
69+
70+
const response = responses[callCount] ?? responses[responses.length - 1]
71+
callCount += 1
72+
return response
73+
})
74+
75+
const result = await pollLoginStatus(
76+
{
77+
fetch: fetchMock as any,
78+
sleep: async () => {},
79+
logger,
80+
},
81+
{
82+
baseUrl: 'https://cli.test',
83+
fingerprintId: 'finger-abc',
84+
fingerprintHash: 'hash-xyz',
85+
expiresAt: '2030-01-02T03:04:05Z',
86+
},
87+
)
88+
89+
expect(result.status).toBe('success')
90+
expect(result.attempts).toBe(3)
91+
expect(result).toHaveProperty('user')
92+
expect(
93+
(result as { user: { id: string } }).user.id,
94+
).toBe('new-user-123')
95+
expect(fetchMock.mock.calls.length).toBe(3)
96+
})
97+
98+
test('pollLoginStatus times out when user never appears', async () => {
99+
const logger = createLogger()
100+
let nowTime = 0
101+
const intervalMs = 5000
102+
const timeoutMs = 20000
103+
104+
const fetchMock = mock(async () => {
105+
return new Response(null, { status: 401 })
106+
})
107+
108+
const sleep = async () => {
109+
nowTime += intervalMs
110+
}
111+
112+
const result = await pollLoginStatus(
113+
{
114+
fetch: fetchMock as any,
115+
sleep,
116+
logger,
117+
now: () => nowTime,
118+
},
119+
{
120+
baseUrl: 'https://cli.test',
121+
fingerprintId: 'finger-timeout',
122+
fingerprintHash: 'hash-timeout',
123+
expiresAt: '2030-01-02T03:04:05Z',
124+
intervalMs,
125+
timeoutMs,
126+
},
127+
)
128+
129+
expect(result.status).toBe('timeout')
130+
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
131+
})
132+
133+
test('pollLoginStatus stops when caller aborts', async () => {
134+
const logger = createLogger()
135+
let attempts = 0
136+
const fetchMock = mock(async () => {
137+
attempts += 1
138+
return new Response(null, { status: 401 })
139+
})
140+
141+
let shouldContinue = true
142+
143+
const resultPromise = pollLoginStatus(
144+
{
145+
fetch: fetchMock as any,
146+
sleep: async () => {
147+
shouldContinue = false
148+
},
149+
logger,
150+
},
151+
{
152+
baseUrl: 'https://cli.test',
153+
fingerprintId: 'finger-abort',
154+
fingerprintHash: 'hash-abort',
155+
expiresAt: '2030-01-02T03:04:05Z',
156+
shouldContinue: () => shouldContinue,
157+
},
158+
)
159+
160+
const result = await resultPromise
161+
expect(result.status).toBe('aborted')
162+
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
163+
})
164+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
2+
import fs from 'fs'
3+
import os from 'os'
4+
import path from 'path'
5+
6+
import {
7+
saveUserCredentials,
8+
getUserCredentials,
9+
logoutUser,
10+
type User,
11+
} from '../../utils/auth'
12+
13+
import type { Logger } from '@codebuff/common/types/contracts/logger'
14+
15+
const ORIGINAL_USER: User = {
16+
id: 'user-001',
17+
name: 'CLI Tester',
18+
email: 'tester@codebuff.dev',
19+
authToken: 'token-original',
20+
fingerprintId: 'fingerprint-original',
21+
fingerprintHash: 'fingerprint-hash-original',
22+
}
23+
24+
const RELOGIN_USER: User = {
25+
...ORIGINAL_USER,
26+
authToken: 'token-after-relogin',
27+
fingerprintId: 'fingerprint-new',
28+
fingerprintHash: 'fingerprint-hash-new',
29+
}
30+
31+
const createLogger = (): Logger & Record<string, ReturnType<typeof mock>> => ({
32+
info: mock(() => {}),
33+
error: mock(() => {}),
34+
warn: mock(() => {}),
35+
debug: mock(() => {}),
36+
})
37+
38+
describe('Logout and Re-login helpers', () => {
39+
let tempConfigDir: string
40+
41+
beforeEach(() => {
42+
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manicode-logout-'))
43+
})
44+
45+
afterEach(() => {
46+
if (fs.existsSync(tempConfigDir)) {
47+
fs.rmSync(tempConfigDir, { recursive: true, force: true })
48+
}
49+
mock.restore()
50+
})
51+
52+
const mockConfigPaths = () => {
53+
const authModule = require('../../utils/auth') as typeof import('../../utils/auth')
54+
spyOn(authModule, 'getConfigDir').mockReturnValue(tempConfigDir)
55+
spyOn(authModule, 'getCredentialsPath').mockReturnValue(
56+
path.join(tempConfigDir, 'credentials.json'),
57+
)
58+
}
59+
60+
test('logoutUser removes credentials file and returns true', async () => {
61+
mockConfigPaths()
62+
saveUserCredentials(ORIGINAL_USER)
63+
64+
const credentialsPath = path.join(tempConfigDir, 'credentials.json')
65+
expect(fs.existsSync(credentialsPath)).toBe(true)
66+
67+
const result = await logoutUser(createLogger())
68+
expect(result).toBe(true)
69+
expect(fs.existsSync(credentialsPath)).toBe(false)
70+
})
71+
72+
test('re-login can persist new credentials after logout', async () => {
73+
mockConfigPaths()
74+
75+
saveUserCredentials(ORIGINAL_USER)
76+
const firstLoaded = getUserCredentials()
77+
expect(firstLoaded?.authToken).toBe('token-original')
78+
79+
await logoutUser(createLogger())
80+
expect(getUserCredentials()).toBeNull()
81+
82+
saveUserCredentials(RELOGIN_USER)
83+
const reloaded = getUserCredentials()
84+
expect(reloaded?.authToken).toBe('token-after-relogin')
85+
expect(reloaded?.fingerprintId).toBe('fingerprint-new')
86+
})
87+
88+
test('logoutUser is idempotent when credentials are already missing', async () => {
89+
mockConfigPaths()
90+
91+
const resultFirst = await logoutUser(createLogger())
92+
expect(resultFirst).toBe(true)
93+
94+
const resultSecond = await logoutUser(createLogger())
95+
expect(resultSecond).toBe(true)
96+
})
97+
})

0 commit comments

Comments
 (0)