diff --git a/routes/governance.js b/routes/governance.js index dad5907..3e4719c 100644 --- a/routes/governance.js +++ b/routes/governance.js @@ -6,10 +6,23 @@ const securityLog = require("../lib/securityLog"); router.post("/govlist", async (req, res) => { try { const gobj = await rpcServices(client.callRpc).gObject_list().call(); - const list = Object.keys(gobj).map(key => { + const list = []; + Object.keys(gobj).forEach(key => { const entry = gobj[key]; - const dataString = JSON.parse(entry.DataString); - return { + let dataString; + try { + dataString = JSON.parse(entry.DataString); + } catch (e) { + securityLog.event('govlist.malformed_data_string', { + req, + key, + hash: entry && entry.Hash, + message: e && e.message, + }); + return; + } + + list.push({ Key: key, Hash: entry.Hash, ColHash: entry.CollateralHash, @@ -26,7 +39,7 @@ router.post("/govlist", async (req, res) => { fCachedDelete: entry.fCachedDelete, fCachedEndorsed: entry.fCachedEndorsed, ...dataString - }; + }); }); list.sort((a, b) => b.AbsoluteYesCount - a.AbsoluteYesCount); diff --git a/routes/mnSearch.js b/routes/mnSearch.js index f1febd7..b9db354 100644 --- a/routes/mnSearch.js +++ b/routes/mnSearch.js @@ -12,22 +12,63 @@ const router = express.Router(); // uses an arrow function for exactly this reason. const dataStore = require("../data/dataStore"); +function endpointHost(value, { stripUnbracketedIpv6Port = false } = {}) { + const text = String(value || ""); + if (!text) return ""; + + if (text.startsWith("[")) { + const end = text.indexOf("]"); + if (end > 0) return text.slice(1, end); + } + + const firstColon = text.indexOf(":"); + const lastColon = text.lastIndexOf(":"); + if (firstColon === -1) return text; + + const tail = text.slice(lastColon + 1); + if (firstColon === lastColon) { + return /^\d+$/.test(tail) ? text.slice(0, lastColon) : text; + } + + if (stripUnbracketedIpv6Port && /^\d+$/.test(tail)) { + return text.slice(0, lastColon); + } + + return text; +} + +function searchHostCandidates(search) { + const base = endpointHost(search); + const stripped = endpointHost(search, { stripUnbracketedIpv6Port: true }); + return [...new Set([base, stripped].filter(Boolean))]; +} + router.post("/mnsearch", (req, res) => { const { page = 1, sortBy = "", sortDesc = false } = req.body; const perPage = req.body.perPage > 0 && req.body.perPage <= 90 ? req.body.perPage : 30; const search = (req.body.search || "").replace(/ /g, ""); - const query = search.includes(":") ? search.split(":")[0] : search; + const query = endpointHost(search); + const queryHosts = searchHostCandidates(search); + const isIpv6Query = queryHosts.some(candidate => candidate.includes(":")); const masternodesArr = Array.isArray(dataStore.masternodesArr) ? dataStore.masternodesArr : []; const filtered = masternodesArr - .filter(mn => - mn.address.split(":")[0].includes(query) || - mn.payee.toUpperCase().includes(query.toUpperCase()) - ) + .filter(mn => { + const addressHost = endpointHost(mn.address, { + stripUnbracketedIpv6Port: true, + }); + const addressMatch = isIpv6Query + ? queryHosts.includes(addressHost) + : addressHost.includes(query); + return ( + addressMatch || + String(mn.payee || "").toUpperCase().includes(query.toUpperCase()) + ); + }) .map(mn => { const clone = { ...mn }; clone.lastpaidtimeS = clone.lastpaidtime || -Infinity; @@ -53,3 +94,4 @@ router.post("/mnsearch", (req, res) => { }); module.exports = router; +module.exports.endpointHost = endpointHost; diff --git a/tests/governance.routes.test.js b/tests/governance.routes.test.js new file mode 100644 index 0000000..f24cf28 --- /dev/null +++ b/tests/governance.routes.test.js @@ -0,0 +1,96 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../services/rpcClient', () => ({ + client: { callRpc: jest.fn() }, + rpcServices: jest.fn(), +})); + +jest.mock('../lib/securityLog', () => ({ + event: jest.fn(), +})); + +const { rpcServices } = require('../services/rpcClient'); +const securityLog = require('../lib/securityLog'); +const governanceRoute = require('../routes/governance'); + +function buildApp() { + const app = express(); + app.use(governanceRoute); + return app; +} + +function mockGObjectList(value) { + rpcServices.mockReturnValue({ + gObject_list: () => ({ + call: jest.fn().mockResolvedValue(value), + }), + }); +} + +describe('POST /govlist', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('skips malformed DataString entries without failing the whole feed', async () => { + mockGObjectList({ + valid: { + Hash: 'valid-hash', + CollateralHash: 'collateral', + ObjectType: 1, + CreationTime: 123, + AbsoluteYesCount: 9, + YesCount: 10, + NoCount: 1, + AbstainCount: 0, + fBlockchainValidity: true, + IsValidReason: '', + fCachedValid: true, + fCachedFunding: true, + fCachedDelete: false, + fCachedEndorsed: false, + DataString: JSON.stringify({ name: 'valid proposal' }), + }, + malformed: { + Hash: 'bad-hash', + AbsoluteYesCount: 99, + DataString: '{not json', + }, + }); + + const res = await request(buildApp()).post('/govlist').send({}); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + Key: 'valid', + Hash: 'valid-hash', + name: 'valid proposal', + }); + expect(securityLog.event).toHaveBeenCalledWith( + 'govlist.malformed_data_string', + expect.objectContaining({ + key: 'malformed', + hash: 'bad-hash', + }) + ); + }); + + test('still returns a generic 500 when the RPC call itself fails', async () => { + rpcServices.mockReturnValue({ + gObject_list: () => ({ + call: jest.fn().mockRejectedValue(new Error('rpc down')), + }), + }); + + const res = await request(buildApp()).post('/govlist').send({}); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'internal' }); + expect(securityLog.event).toHaveBeenCalledWith( + 'govlist.rpc_failed', + expect.objectContaining({ message: 'rpc down' }) + ); + }); +}); diff --git a/tests/mnSearch.routes.test.js b/tests/mnSearch.routes.test.js index 901a8e5..cdc86c9 100644 --- a/tests/mnSearch.routes.test.js +++ b/tests/mnSearch.routes.test.js @@ -118,6 +118,42 @@ describe('POST /mnsearch — live dataStore read', () => { expect(res.body.returnArr[0].address).toBe('203.0.113.7:18370'); }); + test('filters exact IPv6 hosts without truncating at the first colon', async () => { + dataStore.masternodesArr = [ + makeNode({ address: '2001:db8::1:18370', payee: 'sys1qa' }), + makeNode({ address: '2001:db8::2:18370', payee: 'sys1qb' }), + ]; + const res = await request(buildApp()) + .post('/mnsearch') + .send({ search: '2001:db8::1' }); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].address).toBe('2001:db8::1:18370'); + }); + + test('filters unbracketed IPv6 endpoints when the search includes the port', async () => { + dataStore.masternodesArr = [ + makeNode({ address: '2001:db8::1:18370', payee: 'sys1qa' }), + makeNode({ address: '2001:db8::2:18370', payee: 'sys1qb' }), + ]; + const res = await request(buildApp()) + .post('/mnsearch') + .send({ search: '2001:db8::1:18370' }); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].address).toBe('2001:db8::1:18370'); + }); + + test('filters bracketed IPv6 endpoints by host', async () => { + dataStore.masternodesArr = [ + makeNode({ address: '[2001:db8::1]:18370', payee: 'sys1qa' }), + makeNode({ address: '[2001:db8::2]:18370', payee: 'sys1qb' }), + ]; + const res = await request(buildApp()) + .post('/mnsearch') + .send({ search: '[2001:db8::2]:18370' }); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].address).toBe('[2001:db8::2]:18370'); + }); + test('paginates with caller-supplied perPage (clamped to <=90)', async () => { dataStore.masternodesArr = Array.from({ length: 50 }, (_, i) => makeNode({ address: `10.0.0.${i}:18370`, payee: `sys1qpayee${i}` })