Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions server/services/senseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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})`));
Expand Down
70 changes: 70 additions & 0 deletions server/tests/senseService.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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;
Expand Down
Loading