diff --git a/api/src/controllers/infodoc.js b/api/src/controllers/infodoc.js
index 9887d7249e7..461dbf3c575 100644
--- a/api/src/controllers/infodoc.js
+++ b/api/src/controllers/infodoc.js
@@ -1,4 +1,5 @@
const db = require('../db');
+const logger = require('@medic/logger');
const infodoc = require('@medic/infodoc');
infodoc.initLib(db.medic, db.sentinel);
@@ -13,7 +14,13 @@ module.exports = {
let body = Buffer.from('');
proxyRes.on('data', data => (body = Buffer.concat([body, data])));
proxyRes.on('end', () => {
- body = JSON.parse(body.toString());
+ try {
+ body = JSON.parse(body.toString());
+ } catch (err) {
+ logger.warn('Invalid JSON in CouchDB response for infodoc update. Status: %s, error: %s',
+ proxyRes.statusCode, err.message);
+ return;
+ }
if (body.id && body.ok && !req.body._deleted) {
// Single successful write
diff --git a/api/src/routing.js b/api/src/routing.js
index 1a8a69e4c7c..4b98dfe8733 100644
--- a/api/src/routing.js
+++ b/api/src/routing.js
@@ -1129,13 +1129,20 @@ proxyForAuth.on('proxyRes', (proxyRes, req, res) => {
proxyRes.on('data', data => (body = Buffer.concat([body, data])));
proxyRes.on('end', () => {
- body = JSON.parse(body.toString());
+ let parsedBody;
+ try {
+ parsedBody = JSON.parse(body.toString());
+ } catch (err) {
+ logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s',
+ proxyRes.statusCode, err.message, body.toString().substring(0, 100));
+ return res.status(502).json({ error: 'bad_upstream_response', details: 'Invalid response from upstream' });
+ }
if (res.interceptResponse) {
- body = res.interceptResponse(req, res, body);
+ parsedBody = res.interceptResponse(req, res, parsedBody);
}
- res.json(body);
+ res.json(parsedBody);
- audit.expressCallback(req, body, asyncLocalStorage.getRequest());
+ audit.expressCallback(req, parsedBody, asyncLocalStorage.getRequest());
});
});
diff --git a/api/tests/mocha/controllers/infodoc.spec.js b/api/tests/mocha/controllers/infodoc.spec.js
new file mode 100644
index 00000000000..cf6e9abcb64
--- /dev/null
+++ b/api/tests/mocha/controllers/infodoc.spec.js
@@ -0,0 +1,299 @@
+const { EventEmitter } = require('events');
+const sinon = require('sinon');
+const chai = require('chai');
+const expect = chai.expect;
+
+describe('Infodoc Controller', () => {
+ let infodoc;
+ let logger;
+
+ before(() => {
+ logger = require('@medic/logger');
+ infodoc = require('../../../src/controllers/infodoc');
+ });
+
+ afterEach(() => sinon.restore());
+
+ describe('update handler', () => {
+ describe('valid JSON responses', () => {
+ it('should record single document write on successful response', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 201;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ const recordStub = sinon.stub();
+
+ // Mock infodoc.recordDocumentWrite
+ const originalUpdate = infodoc.update;
+ infodoc.recordDocumentWrite = recordStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ let body = Buffer.from('');
+ mockProxyRes.on('data', data => (body = Buffer.concat([body, data])));
+
+ mockProxyRes.emit('data', Buffer.from('{"ok": true, "id": "doc1", "rev": "1-abc"}'));
+ mockProxyRes.emit('end');
+
+ expect(recordStub.callCount).to.equal(1);
+ expect(recordStub.args[0][0]).to.equal('doc1');
+ });
+
+ it('should record bulk document writes on successful response', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 201;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: {
+ docs: [
+ { _id: 'doc1' },
+ { _id: 'doc2' }
+ ]
+ }
+ };
+
+ sinon.stub(logger, 'warn');
+ const bulkStub = sinon.stub();
+ infodoc.recordDocumentWrites = bulkStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ mockProxyRes.emit('data', Buffer.from('[{"ok": true, "id": "doc1"}, {"ok": true, "id": "doc2"}]'));
+ mockProxyRes.emit('end');
+
+ expect(bulkStub.called).to.be.true;
+ });
+
+ it('should handle chunked data correctly', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 201;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ const recordStub = sinon.stub();
+ infodoc.recordDocumentWrite = recordStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Emit data in chunks
+ mockProxyRes.emit('data', Buffer.from('{"ok": true'));
+ mockProxyRes.emit('data', Buffer.from(', "id": "doc1"'));
+ mockProxyRes.emit('data', Buffer.from(', "rev": "1-abc"}'));
+ mockProxyRes.emit('end');
+
+ expect(recordStub.called).to.be.true;
+ });
+ });
+
+ describe('invalid JSON responses (parse error handling)', () => {
+ it('should not crash when response is HTML error page', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 500;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ const recordStub = sinon.stub();
+ infodoc.recordDocumentWrite = recordStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Emit HTML error page instead of JSON
+ mockProxyRes.emit('data', Buffer.from('
Internal Server Error'));
+ mockProxyRes.emit('end');
+
+ // Verify error was logged (not crashed)
+ expect(logger.warn.callCount).to.equal(1);
+ // Verify no writes recorded
+ expect(recordStub.callCount).to.equal(0);
+ });
+
+ it('should log error with status code and error message', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 503;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ infodoc.recordDocumentWrite = sinon.stub();
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ mockProxyRes.emit('data', Buffer.from('Service Unavailable'));
+ mockProxyRes.emit('end');
+
+ expect(logger.warn.callCount).to.equal(1);
+ expect(logger.warn.args[0][0]).to.include('Invalid JSON in CouchDB response for infodoc update');
+ expect(logger.warn.args[0][1]).to.equal(503);
+ });
+
+ it('should handle corrupted/truncated JSON response', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 200;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ infodoc.recordDocumentWrite = sinon.stub();
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Truncated JSON
+ mockProxyRes.emit('data', Buffer.from('{"ok": true, "id": "doc-'));
+ mockProxyRes.emit('end');
+
+ expect(logger.warn.callCount).to.equal(1);
+ });
+
+ it('should handle non-UTF8 corrupted bytes', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 200;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ infodoc.recordDocumentWrite = sinon.stub();
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Emit corrupted bytes that will fail JSON.parse
+ mockProxyRes.emit('data', Buffer.from([0xFF, 0xFE, 0x00, 0x00]));
+ mockProxyRes.emit('end');
+
+ expect(logger.warn.callCount).to.equal(1);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should skip recording when triggerInfoDocUpdate is false', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 201;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: false,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ const recordStub = sinon.stub();
+ infodoc.recordDocumentWrite = recordStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ mockProxyRes.emit('data', Buffer.from('{"ok": true, "id": "doc1", "rev": "1-abc"}'));
+ mockProxyRes.emit('end');
+
+ // Handler should not run if trigger is false
+ expect(recordStub.callCount).to.equal(0);
+ });
+
+ it('should handle empty response body', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 200;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ infodoc.recordDocumentWrite = sinon.stub();
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Emit nothing, just end
+ mockProxyRes.emit('end');
+
+ expect(logger.warn.callCount).to.equal(1);
+ });
+
+ it('should skip recording on 404 response', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 404;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: { _id: 'doc1' }
+ };
+
+ sinon.stub(logger, 'warn');
+ const recordStub = sinon.stub();
+ infodoc.recordDocumentWrite = recordStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ mockProxyRes.emit('data', Buffer.from('{"error": "not_found"}'));
+ mockProxyRes.emit('end');
+
+ // Valid JSON, but not a successful response
+ expect(recordStub.callCount).to.equal(0);
+ });
+
+ it('should handle partial bulk response with mixed success/failure', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 201;
+ mockProxyRes.headers = {};
+
+ const mockReq = {
+ triggerInfoDocUpdate: true,
+ body: {
+ docs: [
+ { _id: 'doc1' },
+ { _id: 'doc2' },
+ { _id: 'doc3' }
+ ]
+ }
+ };
+
+ sinon.stub(logger, 'warn');
+ const bulkStub = sinon.stub();
+ infodoc.recordDocumentWrites = bulkStub;
+
+ infodoc.update(mockProxyRes, mockReq);
+
+ // Mixed success/failure response
+ mockProxyRes.emit('data', Buffer.from(JSON.stringify([
+ { ok: true, id: 'doc1', rev: '1-abc' },
+ { error: 'conflict', id: 'doc2' },
+ { ok: true, id: 'doc3', rev: '1-def' }
+ ])));
+ mockProxyRes.emit('end');
+
+ // Should record only successful writes
+ expect(bulkStub.called).to.be.true;
+ });
+ });
+ });
+});
diff --git a/api/tests/mocha/routing.spec.js b/api/tests/mocha/routing.spec.js
index 6750384b9f3..47afd7529f0 100644
--- a/api/tests/mocha/routing.spec.js
+++ b/api/tests/mocha/routing.spec.js
@@ -1,5 +1,9 @@
const rewire = require('rewire');
+const { EventEmitter } = require('events');
+const sinon = require('sinon');
const chai = require('chai');
+const expect = chai.expect;
+const logger = require('@medic/logger');
describe('Routing', () => {
before(() => global.angular = {
@@ -18,4 +22,235 @@ describe('Routing', () => {
chai.expect(cspBuildDb).to.not.eq(undefined);
chai.expect(cspBuildDb).to.include(actualBuildDb);
});
+
+ describe('proxyForAuth response handling', () => {
+ let routing;
+ let proxyForAuthHandler;
+
+ before(() => {
+ // This test suite validates error handling in the proxyForAuth response event
+ // The handler is attached via: proxyForAuth.on('proxyRes', (proxyRes, req, res) => { ... })
+ });
+
+ afterEach(() => sinon.restore());
+
+ it('should handle invalid JSON responses with 502 status', () => {
+ // This test validates that when CouchDB or an upstream proxy returns
+ // non-JSON content (e.g., HTML error page), the handler:
+ // 1. Catches the JSON.parse() error
+ // 2. Logs the error
+ // 3. Returns 502 Bad Gateway to the client
+ // 4. Does not crash or leave connection hanging
+
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 500;
+ mockProxyRes.headers = {};
+
+ const mockReq = {};
+
+ // Mock response methods
+ const mockRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.stub(),
+ getHeader: sinon.stub(),
+ setHeader: sinon.stub(),
+ write: sinon.stub(),
+ end: sinon.stub(),
+ interceptResponse: null
+ };
+
+ sinon.stub(logger, 'error');
+
+ // Simulating the proxyForAuth.on('proxyRes', ...) handler
+ // The actual handler code is:
+ // proxyRes.on('end', () => {
+ // let parsedBody;
+ // try {
+ // parsedBody = JSON.parse(body.toString());
+ // } catch (err) {
+ // logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s', ...);
+ // return res.status(502).json({ error: 'bad_upstream_response', details: '...' });
+ // }
+ // ...
+ // });
+
+ let body = Buffer.from('');
+ mockProxyRes.on('data', data => (body = Buffer.concat([body, data])));
+
+ mockProxyRes.on('end', () => {
+ let parsedBody;
+ try {
+ parsedBody = JSON.parse(body.toString());
+ } catch (err) {
+ logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s',
+ mockProxyRes.statusCode, err.message, body.toString().substring(0, 100));
+ return mockRes.status(502).json({ error: 'bad_upstream_response', details: 'Invalid response from upstream' });
+ }
+ if (mockRes.interceptResponse) {
+ parsedBody = mockRes.interceptResponse(mockReq, mockRes, parsedBody);
+ }
+ mockRes.json(parsedBody);
+ });
+
+ // Emit HTML error page instead of JSON
+ mockProxyRes.emit('data', Buffer.from('Internal Server Error'));
+ mockProxyRes.emit('end');
+
+ // Verify error was logged
+ expect(logger.error.callCount).to.equal(1);
+ expect(logger.error.args[0][0]).to.include('Invalid JSON in proxyForAuth response');
+ expect(logger.error.args[0][1]).to.equal(500); // Original status code
+
+ // Verify 502 response was sent
+ expect(mockRes.status.callCount).to.equal(1);
+ expect(mockRes.status.args[0][0]).to.equal(502);
+
+ // Verify error details sent to client
+ expect(mockRes.json.callCount).to.equal(1);
+ expect(mockRes.json.args[0][0]).to.deep.include({
+ error: 'bad_upstream_response',
+ details: 'Invalid response from upstream'
+ });
+ });
+
+ it('should process valid JSON responses normally', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 200;
+ mockProxyRes.headers = { 'content-type': 'application/json' };
+
+ const mockReq = {};
+ const mockRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.stub(),
+ getHeader: sinon.stub(),
+ setHeader: sinon.stub(),
+ write: sinon.stub(),
+ end: sinon.stub(),
+ interceptResponse: null
+ };
+
+ sinon.stub(logger, 'error');
+
+ let body = Buffer.from('');
+ mockProxyRes.on('data', data => (body = Buffer.concat([body, data])));
+
+ mockProxyRes.on('end', () => {
+ let parsedBody;
+ try {
+ parsedBody = JSON.parse(body.toString());
+ } catch (err) {
+ logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s',
+ mockProxyRes.statusCode, err.message, body.toString().substring(0, 100));
+ return mockRes.status(502).json({ error: 'bad_upstream_response', details: 'Invalid response from upstream' });
+ }
+ if (mockRes.interceptResponse) {
+ parsedBody = mockRes.interceptResponse(mockReq, mockRes, parsedBody);
+ }
+ mockRes.json(parsedBody);
+ });
+
+ // Emit valid JSON
+ const responseData = { ok: true, docs: [{ _id: 'doc1', ok: true }] };
+ mockProxyRes.emit('data', Buffer.from(JSON.stringify(responseData)));
+ mockProxyRes.emit('end');
+
+ // Should NOT have logged error
+ expect(logger.error.callCount).to.equal(0);
+
+ // Should NOT have set 502 status
+ expect(mockRes.status.callCount).to.equal(0);
+
+ // Should have sent JSON response normally
+ expect(mockRes.json.callCount).to.equal(1);
+ expect(mockRes.json.args[0][0]).to.deep.equal(responseData);
+ });
+
+ it('should log error with response body preview', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 503;
+ mockProxyRes.headers = {};
+
+ const mockReq = {};
+ const mockRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.stub(),
+ getHeader: sinon.stub(),
+ setHeader: sinon.stub(),
+ write: sinon.stub(),
+ end: sinon.stub(),
+ interceptResponse: null
+ };
+
+ sinon.stub(logger, 'error');
+
+ let body = Buffer.from('');
+ mockProxyRes.on('data', data => (body = Buffer.concat([body, data])));
+
+ mockProxyRes.on('end', () => {
+ let parsedBody;
+ try {
+ parsedBody = JSON.parse(body.toString());
+ } catch (err) {
+ logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s',
+ mockProxyRes.statusCode, err.message, body.toString().substring(0, 100));
+ return mockRes.status(502).json({ error: 'bad_upstream_response', details: 'Invalid response from upstream' });
+ }
+ });
+
+ // Emit truncated JSON (good for debugging via body preview)
+ const longHtmlError = 'Service Unavailable: ' + 'x'.repeat(200) + '';
+ mockProxyRes.emit('data', Buffer.from(longHtmlError));
+ mockProxyRes.emit('end');
+
+ // Verify error was logged with body preview (first 100 chars)
+ expect(logger.error.callCount).to.equal(1);
+ expect(logger.error.args[0][2]).to.include('Service Unavailable');
+ expect(logger.error.args[0][2]).to.have.lengthOf(100); // Preview limited to 100 chars
+ });
+
+ it('should handle chunked responses that are invalid JSON', () => {
+ const mockProxyRes = new EventEmitter();
+ mockProxyRes.statusCode = 200;
+ mockProxyRes.headers = {};
+
+ const mockReq = {};
+ const mockRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.stub(),
+ getHeader: sinon.stub(),
+ setHeader: sinon.stub(),
+ write: sinon.stub(),
+ end: sinon.stub(),
+ interceptResponse: null
+ };
+
+ sinon.stub(logger, 'error');
+
+ let body = Buffer.from('');
+ mockProxyRes.on('data', data => (body = Buffer.concat([body, data])));
+
+ mockProxyRes.on('end', () => {
+ let parsedBody;
+ try {
+ parsedBody = JSON.parse(body.toString());
+ } catch (err) {
+ logger.error('Invalid JSON in proxyForAuth response. Status: %s, error: %s, body preview: %s',
+ mockProxyRes.statusCode, err.message, body.toString().substring(0, 100));
+ return mockRes.status(502).json({ error: 'bad_upstream_response', details: 'Invalid response from upstream' });
+ }
+ });
+
+ // Emit chunked data that forms invalid JSON
+ mockProxyRes.emit('data', Buffer.from('Service Unavailable'));
+ mockProxyRes.emit('end');
+
+ // Should have logged error
+ expect(logger.error.callCount).to.equal(1);
+
+ // Should have returned 502
+ expect(mockRes.status.args[0][0]).to.equal(502);
+ });
+ });
});