const fs = require('fs').promises; const os = require('os'); const path = require('path'); const WebexServiceAppAuth = require('../integrations/webex/WebexServiceAppAuth'); function makeMockAxios(impl) { return { post: jest.fn(impl) }; } describe('WebexServiceAppAuth', () => { let tmpFile; beforeEach(async () => { WebexServiceAppAuth.resetForTests(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webex-auth-')); tmpFile = path.join(tmpDir, 'tokens.json'); }); afterEach(async () => { WebexServiceAppAuth.resetForTests(); try { await fs.rm(path.dirname(tmpFile), { recursive: true, force: true }); } catch (_e) { /* tmp cleanup failures shouldn't fail the suite */ } }); it('refuses to construct without client id / secret', () => { WebexServiceAppAuth.resetForTests(); expect(() => new WebexServiceAppAuth({ clientId: '', clientSecret: 's' })).toThrow( /WEBEX_CLIENT_ID/ ); expect(() => new WebexServiceAppAuth({ clientId: 'c', clientSecret: '' })).toThrow( /WEBEX_CLIENT_SECRET/ ); }); it('round-trips tokens through the tokens file', async () => { const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, }); auth.accessToken = 'at-1'; auth.refreshToken = 'rt-1'; auth.expiresAt = 1234567890000; await auth.saveTokens(); const auth2 = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, }); await auth2.loadTokens(); expect(auth2.accessToken).toBe('at-1'); expect(auth2.refreshToken).toBe('rt-1'); expect(auth2.expiresAt).toBe(1234567890000); }); it('loadTokens rejects with ENOENT when file is missing', async () => { const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: path.join(path.dirname(tmpFile), 'no-such-file.json'), }); await expect(auth.loadTokens()).rejects.toMatchObject({ code: 'ENOENT' }); }); it('refresh() persists rotated tokens and applies the 5-min safety buffer', async () => { const expiresIn = 3600; // 1 hour const mockHttp = makeMockAxios(async () => ({ data: { access_token: 'new-access', refresh_token: 'new-refresh', expires_in: expiresIn, }, })); const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, httpClient: mockHttp, }); auth.refreshToken = 'old-refresh'; const beforeMs = Date.now(); const token = await auth.refresh(); const afterMs = Date.now(); expect(token).toBe('new-access'); expect(auth.refreshToken).toBe('new-refresh'); expect(mockHttp.post).toHaveBeenCalledTimes(1); // Buffer = 5 minutes early ⇒ expiresAt ≈ now + expiresIn*1000 - 5min. const expectedLow = beforeMs + expiresIn * 1000 - 5 * 60 * 1000; const expectedHigh = afterMs + expiresIn * 1000 - 5 * 60 * 1000; expect(auth.expiresAt).toBeGreaterThanOrEqual(expectedLow); expect(auth.expiresAt).toBeLessThanOrEqual(expectedHigh); // And it persisted on disk: const raw = await fs.readFile(tmpFile, 'utf8'); expect(JSON.parse(raw)).toMatchObject({ accessToken: 'new-access', refreshToken: 'new-refresh', }); }); it('refresh() throws a re-seed hint on 400/401 from Webex', async () => { const mockHttp = makeMockAxios(async () => { const err = new Error('Bad Request'); err.response = { status: 400, data: { error: 'invalid_grant' } }; throw err; }); const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, httpClient: mockHttp, }); auth.refreshToken = 'old'; await expect(auth.refresh()).rejects.toThrow(/webex:seed/); }); it('refresh() throws clearly when no refresh token is available', async () => { const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, }); await expect(auth.refresh()).rejects.toThrow(/No refresh token/); }); it('getAccessToken() refreshes when expiresAt is past', async () => { const mockHttp = makeMockAxios(async () => ({ data: { access_token: 'refreshed', refresh_token: 'rt2', expires_in: 3600 }, })); const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, httpClient: mockHttp, }); auth.accessToken = 'stale'; auth.refreshToken = 'old'; auth.expiresAt = Date.now() - 1000; // already expired const token = await auth.getAccessToken(); expect(token).toBe('refreshed'); expect(mockHttp.post).toHaveBeenCalledTimes(1); }); it('getAccessToken() returns the cached token when not expired', async () => { const mockHttp = makeMockAxios(async () => { throw new Error('should not be called'); }); const auth = new WebexServiceAppAuth({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, httpClient: mockHttp, }); auth.accessToken = 'fresh'; auth.refreshToken = 'rt'; auth.expiresAt = Date.now() + 60 * 1000; const token = await auth.getAccessToken(); expect(token).toBe('fresh'); expect(mockHttp.post).not.toHaveBeenCalled(); }); it('getInstance() returns a singleton until resetForTests is called', () => { const a1 = WebexServiceAppAuth.getInstance({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, }); const a2 = WebexServiceAppAuth.getInstance({ clientId: 'other', clientSecret: 'x', tokensFilePath: tmpFile, }); expect(a2).toBe(a1); WebexServiceAppAuth.resetForTests(); const a3 = WebexServiceAppAuth.getInstance({ clientId: 'c', clientSecret: 's', tokensFilePath: tmpFile, }); expect(a3).not.toBe(a1); }); });