diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 5e129072..8bd331b7 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -2,7 +2,7 @@ * Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。 */ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' @@ -75,6 +75,36 @@ describe('HahaOpenAIOAuthService — file storage', () => { await service.deleteTokens() expect(await service.loadTokens()).toBeNull() }) + + test('saveTokens cleans up tmp file when rename fails', async () => { + const renameSpy = spyOn(fs, 'rename').mockImplementation(async () => { + const error = new Error('rename failed') as NodeJS.ErrnoException + error.code = 'EXDEV' + throw error + }) + + try { + await expect( + service.saveTokens({ + accessToken: 'sensitive-access', + refreshToken: 'sensitive-refresh', + expiresAt: Date.now() + 3600_000, + idToken: 'sensitive-id-token', + email: 'test@example.com', + accountId: 'acct_123', + }), + ).rejects.toThrow('rename failed') + } finally { + renameSpy.mockRestore() + } + + const oauthPath = getHahaOpenAIOAuthFilePath() + const files = await fs.readdir(path.dirname(oauthPath)) + expect( + files.filter((name) => name.startsWith('openai-oauth.json.tmp.')), + ).toEqual([]) + expect(await service.loadTokens()).toBeNull() + }) }) describe('HahaOpenAIOAuthService — session management', () => { diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index b9dde1af..74738b9c 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -84,9 +84,17 @@ export class HahaOpenAIOAuthService { async saveTokens(tokens: StoredOpenAIOAuthTokens): Promise { const filePath = this.getOAuthFilePath() await fs.mkdir(path.dirname(filePath), { recursive: true }) - const tmp = `${filePath}.tmp.${process.pid}` - await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 }) - await fs.rename(tmp, filePath) + const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}` + let renamed = false + try { + await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 }) + await fs.rename(tmp, filePath) + renamed = true + } finally { + if (!renamed) { + await fs.rm(tmp, { force: true }).catch(() => {}) + } + } } async deleteTokens(): Promise {