diff --git a/server/services/senseService.js b/server/services/senseService.js index ed20307d..0c35e692 100644 --- a/server/services/senseService.js +++ b/server/services/senseService.js @@ -1077,14 +1077,19 @@ class SenseService { } if (retryAuth && (response.status === 401 || response.status === 403)) { + let renewError = null; if (trimString(integration?.refreshToken) && trimString(integration?.userId)) { - await this.renewAuth(integration); - return this.requestApi(path, { - integration, - params, - timeout, - retryAuth: false - }); + try { + await this.renewAuth(integration); + return this.requestApi(path, { + integration, + params, + timeout, + retryAuth: false + }); + } catch (error) { + renewError = error; + } } if (trimString(integration?.email) && trimString(integration?.password)) { @@ -1096,6 +1101,10 @@ class SenseService { retryAuth: false }); } + + if (renewError) { + throw renewError; + } } throw new Error(this.extractApiErrorMessage(response.data, `Sense API request failed (${response.status})`)); diff --git a/server/tests/senseService.test.js b/server/tests/senseService.test.js index 4b70f1e2..c1f54654 100644 --- a/server/tests/senseService.test.js +++ b/server/tests/senseService.test.js @@ -1,5 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const axios = require('axios'); const senseService = require('../services/senseService'); const SenseIntegration = require('../models/SenseIntegration'); @@ -191,6 +192,75 @@ test('testConnection reuses persisted credentials when the client keeps a masked assert.equal(result.monitor.name, 'Main Panel'); }); +test('requestApi falls back to full Sense auth when refresh token renewal is rejected', async (t) => { + const service = new senseService.SenseService(); + const originalAxiosGet = axios.get; + const calls = []; + let renewCalled = false; + let authenticateCalled = false; + + const integration = { + email: 'saved@example.com', + password: 'saved-password', + accessToken: 'expired-access-token', + refreshToken: 'expired-refresh-token', + userId: 'user-1', + deviceId: 'device-123', + monitorId: 'monitor-1' + }; + + axios.get = async (url, options = {}) => { + calls.push({ + url, + authorization: options.headers?.Authorization + }); + + if (calls.length === 1) { + return { + status: 401, + data: { + message: 'Failed to authenticate.' + } + }; + } + + return { + status: 200, + data: { + monitor: { + id: 'monitor-1', + name: 'Main Panel' + } + } + }; + }; + + service.renewAuth = async () => { + renewCalled = true; + throw new Error('Failed to authenticate.'); + }; + + service.authenticate = async (targetIntegration) => { + authenticateCalled = true; + assert.equal(targetIntegration.email, 'saved@example.com'); + assert.equal(targetIntegration.password, 'saved-password'); + targetIntegration.accessToken = 'fresh-access-token'; + }; + + t.after(() => { + axios.get = originalAxiosGet; + }); + + const result = await service.requestApi('app/monitors/monitor-1/overview', { integration }); + + assert.equal(renewCalled, true); + assert.equal(authenticateCalled, true); + assert.equal(calls.length, 2); + assert.equal(calls[0].authorization, 'bearer expired-access-token'); + assert.equal(calls[1].authorization, 'bearer fresh-access-token'); + assert.equal(result.monitor.id, 'monitor-1'); +}); + test('updateRealtimeState throttles websocket heartbeat persistence to avoid save storms', async () => { const service = new senseService.SenseService(); const originalUpdateOne = SenseIntegration.updateOne;