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); + }); + }); });