File size: 5,914 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | /**
* Unit tests for HahaOAuthService — haha 自管 OAuth 的核心 service 层。
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import {
HahaOAuthService,
type StoredOAuthTokens,
} from '../services/hahaOAuthService.js'
let tmpDir: string
let originalConfigDir: string | undefined
let service: HahaOAuthService
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-oauth-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
service = new HahaOAuthService()
}
async function teardown() {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
describe('HahaOAuthService — file storage', () => {
beforeEach(setup)
afterEach(teardown)
test('loadTokens returns null when file does not exist', async () => {
expect(await service.loadTokens()).toBeNull()
})
test('saveTokens writes file with 0600 permissions', async () => {
const tokens: StoredOAuthTokens = {
accessToken: 'sk-ant-oat01-xxx',
refreshToken: 'sk-ant-ort01-xxx',
expiresAt: Date.now() + 3600_000,
scopes: ['user:inference', 'user:profile'],
subscriptionType: 'max',
}
await service.saveTokens(tokens)
const oauthPath = path.join(tmpDir, 'cc-haha', 'oauth.json')
const stat = await fs.stat(oauthPath)
if (process.platform !== 'win32') {
expect(stat.mode & 0o777).toBe(0o600)
}
const loaded = await service.loadTokens()
expect(loaded).toEqual(tokens)
})
test('deleteTokens removes file', async () => {
await service.saveTokens({
accessToken: 'a',
refreshToken: null,
expiresAt: null,
scopes: [],
subscriptionType: null,
})
await service.deleteTokens()
expect(await service.loadTokens()).toBeNull()
})
})
describe('HahaOAuthService — session management', () => {
beforeEach(setup)
afterEach(teardown)
test('startSession creates session with PKCE + state', () => {
const session = service.startSession({ serverPort: 54321 })
expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(session.authorizeUrl).toContain('code_challenge_method=S256')
expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`)
expect(session.authorizeUrl).toContain('redirect_uri=')
expect(session.authorizeUrl).toContain(encodeURIComponent(
'http://localhost:54321/callback',
))
})
test('getSession returns stored session by state', () => {
const session = service.startSession({ serverPort: 54321 })
const found = service.getSession(session.state)
expect(found?.codeVerifier).toBe(session.codeVerifier)
})
test('getSession returns null for unknown state', () => {
expect(service.getSession('unknown-state')).toBeNull()
})
test('consumeSession removes session after fetch', () => {
const session = service.startSession({ serverPort: 54321 })
expect(service.consumeSession(session.state)).not.toBeNull()
expect(service.getSession(session.state)).toBeNull()
})
test('completeSession stores subscription type fetched from profile info', async () => {
const session = service.startSession({ serverPort: 54321 })
;(service as any).exchangeWithCustomCallback = async () => ({
access_token: 'fresh-access-token',
refresh_token: 'fresh-refresh-token',
expires_in: 3600,
scope: 'user:inference',
})
service.setFetchProfileFn(async () => ({
subscriptionType: 'team',
}))
const tokens = await service.completeSession('authorization-code', session.state)
expect(tokens.subscriptionType).toBe('team')
expect((await service.loadTokens())?.subscriptionType).toBe('team')
})
})
describe('HahaOAuthService — ensureFreshAccessToken', () => {
beforeEach(setup)
afterEach(teardown)
test('returns null when no token file exists', async () => {
expect(await service.ensureFreshAccessToken()).toBeNull()
})
test('returns token unchanged if not expired', async () => {
const tokens: StoredOAuthTokens = {
accessToken: 'still-valid',
refreshToken: 'refresh-xxx',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
}
await service.saveTokens(tokens)
expect(await service.ensureFreshAccessToken()).toBe('still-valid')
})
test('refreshes token when expired (within 5-min buffer)', async () => {
const oldTokens: StoredOAuthTokens = {
accessToken: 'expired',
refreshToken: 'refresh-xxx',
expiresAt: Date.now() + 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
}
await service.saveTokens(oldTokens)
service.setRefreshFn(async () => ({
accessToken: 'new-fresh-token',
refreshToken: 'new-refresh-xxx',
expiresAt: Date.now() + 3600_000,
scopes: ['user:inference'],
subscriptionType: 'max',
rateLimitTier: null,
}))
const fresh = await service.ensureFreshAccessToken()
expect(fresh).toBe('new-fresh-token')
const loaded = await service.loadTokens()
expect(loaded?.accessToken).toBe('new-fresh-token')
})
test('returns null when refresh fails', async () => {
await service.saveTokens({
accessToken: 'expired',
refreshToken: 'bad-refresh',
expiresAt: Date.now() + 60_000,
scopes: ['user:inference'],
subscriptionType: null,
})
service.setRefreshFn(async () => {
throw new Error('401 Unauthorized')
})
expect(await service.ensureFreshAccessToken()).toBeNull()
})
})
|