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
21 changes: 17 additions & 4 deletions routes/governance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
52 changes: 47 additions & 5 deletions routes/mnSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip unbracketed IPv6 port from search query

When a user searches with an unbracketed IPv6 endpoint that includes a port (for example, copy/pasting 2001:db8::1:18370 from the address column), query keeps the port while each masternode address is normalized with stripUnbracketedIpv6Port: true, so the exact IPv6 comparison can never match. This makes valid IPv6 entries unsearchable in that common input path; normalize the query with the same port-stripping rule (or compare endpoint-to-endpoint consistently) before the addressHost === query check.

Useful? React with 👍 / 👎.

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;
Expand All @@ -53,3 +94,4 @@ router.post("/mnsearch", (req, res) => {
});

module.exports = router;
module.exports.endpointHost = endpointHost;
96 changes: 96 additions & 0 deletions tests/governance.routes.test.js
Original file line number Diff line number Diff line change
@@ -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' })
);
});
});
36 changes: 36 additions & 0 deletions tests/mnSearch.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}` })
Expand Down
Loading