diff --git a/README.md b/README.md index 78bfa71..f60656d 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,28 @@ The public dashboard is served by [sysnode-info](https://github.com/syscoin/sysn ## Runtime surface -Public, unauthenticated routes (cached, read-only): - -- `/mnstats`, `/masternodes`, `/mnlist`, `/mnsearch` — masternode data -- `/governance` — active and historical governance proposals -- `/csvparser` — CSV-ingest helper used by the dashboard +Public, unauthenticated routes (read-only). Canonical URL casing is +**lowercase**, matching the historical `https://syscoin.dev/mnstats` +convention and the existing `/govlist`. Express's default routing is +case-insensitive, so legacy camelCase callers (`/mnStats`, `/mnList`, +…) continue to work at the route layer; the bundled nginx config +(`deploy/nginx/sysnode.conf.example`) uses a case-insensitive regex +match for the same reason, so external bookmarks/clients with mixed +casing keep working through the same-origin proxy. New integrations +should use lowercase. + +- `GET /mnstats` — chain + market + masternode summary, refreshed by `services/sysMain.js` +- `GET /mncount` — historical masternode count series +- `GET /mnlist` — fresh `masternode_list` RPC passthrough +- `POST /mnsearch` — paginated/searched view over the in-memory tracker snapshot +- `POST /govlist` — active + historical governance proposals (Syscoin Core `gobject list`) Authenticated routes (cookie + CSRF, same-site): - `/auth/*` — registration, verification, login, session, delete account -- `/vault/*` — encrypted per-user blobs (notification prefs, proposal drafts) -- `/gov/proposals/*` — governance proposal wizard, submissions, collateral PSBT, vote receipts +- `/vault` — encrypted per-user blob (notification prefs, proposal drafts; one row per user, conditional GET/PUT with ETag) +- `/gov/*` — masternode lookup, vote relay, vote receipts (`/gov/mns/lookup`, `/gov/vote`, `/gov/receipts`, …) +- `/gov/proposals/*` — governance proposal wizard, submissions, collateral PSBT ## Requirements diff --git a/deploy/nginx/sysnode.conf.example b/deploy/nginx/sysnode.conf.example index b9a168b..43bee16 100644 --- a/deploy/nginx/sysnode.conf.example +++ b/deploy/nginx/sysnode.conf.example @@ -12,15 +12,30 @@ # # Layout matches the same-origin model documented in the project README: # -# https://sysnode.example.com/ -> sysnode-info build (port 3000) -# https://sysnode.example.com/auth/* -> sysnode-backend (port 3001) -# https://sysnode.example.com/vault/* -> sysnode-backend (port 3001) -# https://sysnode.example.com/gov/* -> sysnode-backend (port 3001) +# https://sysnode.example.com/ -> sysnode-info build (port 3000) +# https://sysnode.example.com/auth/* -> sysnode-backend (port 3001) +# https://sysnode.example.com/vault -> sysnode-backend (port 3001) +# https://sysnode.example.com/gov/* -> sysnode-backend (port 3001) +# https://sysnode.example.com/mnstats -> sysnode-backend (port 3001) +# https://sysnode.example.com/mncount -> sysnode-backend (port 3001) +# https://sysnode.example.com/mnlist -> sysnode-backend (port 3001) +# https://sysnode.example.com/mnsearch -> sysnode-backend (port 3001) +# https://sysnode.example.com/govlist -> sysnode-backend (port 3001) # -# Public anonymous routes that the dashboard polls (mnstats, mnCount, -# masternodes, governance, etc.) are also served by sysnode-backend. -# They're listed individually so the SPA's catch-all `location /` can -# fall through to the React static build for everything else. +# Public anonymous routes (/mnstats, /mncount, /mnlist, /mnsearch, +# /govlist) are matched with a case-insensitive regex so the SPA's +# catch-all `location /` falls through to the React static build for +# everything else, while legacy camelCase callers (browser bookmarks, +# external integrations that predate the lowercase canonical such as +# the historical `https://syscoin.dev/mnstats` URL still referenced +# by older syshub builds) keep working through the same-origin proxy. +# +# Heads up — paths like `/governance`, `/masternodes`, `/login`, `/vault`- +# beyond-the-bare-segment are *client-side React routes* served by the +# SPA. Don't add nginx `location /governance` / `location /masternodes` +# blocks: the backend doesn't expose those paths (its public routes +# are `/govlist`, `/mnsearch`, `/mnlist`, `/mnstats`, `/mncount`), and +# proxying them to :3001 would 404 the SPA pages users navigate to. # # Do NOT add `add_header Strict-Transport-Security ...` here. Both apps # emit HSTS in code (helmet on the backend, the security-header map in @@ -39,21 +54,32 @@ server { client_max_body_size 5M; # ---- backend (sysnode-backend on :3001) ------------------------------- - # The backend mounts auth/vault/gov at the path root, so we proxy - # without rewriting. Trailing-slash matching means /auth, /authNNN, - # etc. all match — adjust if your backend ever serves something at - # /authentication that should NOT be proxied. - location /auth/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - location /vault/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - location /gov/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } + # The backend mounts /auth, /vault, /gov at the path root, so we proxy + # without rewriting. Trailing-slash matching (`location /auth/`) means + # only paths *under* /auth/ are proxied (e.g. /auth/me, /auth/login); + # something else at /authentication would NOT match and would fall + # through to the SPA. /vault is matched as an exact location because + # the SPA hits exactly `/vault` (GET / PUT of the encrypted blob); + # listing it as a prefix would also catch client-side routes like + # `/vault/import` that need to fall through to the SPA. + location /auth/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } + location = /vault { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } + location /gov/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - # Public anonymous data routes. Listed exactly so / falls - # through to the SPA below. If you add a new public backend route, - # add it here too. - location = /mnstats { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - location = /mnCount { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - location /masternodes { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } - location /governance { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; } + # Public anonymous data routes. Canonical casing is lowercase + # (matches the historical `https://syscoin.dev/mnstats` URL still + # used by older syshub builds and the existing `/govlist`). + # `~*` is a case-INsensitive regex match so legacy callers using + # camelCase URLs (`/mnStats`, `/mnList`, …) keep working through + # the same-origin proxy — this mirrors Express's own default + # case-insensitive route matching at the route layer. The anchored + # `^/...$` keeps `/mnstatsfoo` etc. from being proxied so they fall + # through to the SPA's catch-all `location /` below. If you add a + # new public backend route, extend this alternation. + location ~* ^/(mnstats|mncount|mnlist|mnsearch|govlist)$ { + proxy_pass http://127.0.0.1:3001; + include /etc/nginx/snippets/sysnode-proxy.conf; + } # ---- frontend (sysnode-info on :3000) --------------------------------- # Catch-all for the SPA. server.js falls back to index.html for any diff --git a/lib/reminderDispatcher.test.js b/lib/reminderDispatcher.test.js index a48b326..7a094ad 100644 --- a/lib/reminderDispatcher.test.js +++ b/lib/reminderDispatcher.test.js @@ -634,7 +634,7 @@ describe('createReminderDispatcher.tick', () => { { height: 600_000, epochSec: 0 }, { height: 600_000, epochSec: null }, { height: 600_000, epochSec: NaN }, - // past epoch: /mnStats lagging behind the tip + // past epoch: /mnstats lagging behind the tip { height: 600_000, epochSec: Math.floor((NOW_MS - 10_000) / 1000) }, // epoch equal to now: boundary — also treated as stale { height: 600_000, epochSec: Math.floor(NOW_MS / 1000) }, diff --git a/routes/masternodes.js b/routes/masternodes.js deleted file mode 100644 index 987b941..0000000 --- a/routes/masternodes.js +++ /dev/null @@ -1,42 +0,0 @@ -const express = require("express"); -const moment = require("moment"); -const router = express.Router(); -const { masternodesArr } = require("../data/dataStore"); - -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 filtered = masternodesArr - .filter(mn => - mn.address.split(":")[0].includes(query) || - mn.payee.toUpperCase().includes(query.toUpperCase()) - ) - .map(mn => { - const clone = { ...mn }; - clone.lastpaidtimeS = clone.lastpaidtime || -Infinity; - clone.lastpaidtime = clone.lastpaidtime === 0 ? "Never Paid" : moment.unix(clone.lastpaidtime).fromNow(); - clone.lastseenS = clone.lastseen; - clone.lastseen = moment.unix(clone.lastseen).fromNow(); - return clone; - }); - - if (sortBy === "lastSeen") { - filtered.sort((a, b) => a.lastseenS - b.lastseenS); - } else if (sortBy === "lastPayment") { - filtered.sort((a, b) => a.lastpaidtimeS - b.lastpaidtimeS); - } else if (sortBy) { - filtered.sort((a, b) => (a[sortBy] < b[sortBy] ? -1 : a[sortBy] > b[sortBy] ? 1 : 0)); - } - - if (sortDesc) filtered.reverse(); - - const paginated = filtered.slice((page - 1) * perPage, page * perPage); - - res.status(200).send({ returnArr: paginated, mnNumb: filtered.length }); -}); - -module.exports = router; \ No newline at end of file diff --git a/routes/mnCount.js b/routes/mnCount.js index 9d4c9b9..00370ac 100644 --- a/routes/mnCount.js +++ b/routes/mnCount.js @@ -2,7 +2,7 @@ const express = require('express'); -// GET /mnCount +// GET /mncount // ------------ // Historical daily total of masternodes on the network, used by the // TrendChart component on sysnode-info's homepage. @@ -28,7 +28,7 @@ function createMnCountRouter({ repo, log = () => {} } = {}) { } const router = express.Router(); - router.get('/mnCount', (_req, res) => { + router.get('/mncount', (_req, res) => { try { const rows = repo.getAll(); res.json(rows); diff --git a/routes/mnCount.test.js b/routes/mnCount.test.js index cb10a59..e55d636 100644 --- a/routes/mnCount.test.js +++ b/routes/mnCount.test.js @@ -12,7 +12,7 @@ function mountApp(router) { return app; } -describe('GET /mnCount', () => { +describe('GET /mncount', () => { let db; let repo; @@ -25,7 +25,7 @@ describe('GET /mnCount', () => { test('empty table → 200 with []', async () => { const app = mountApp(createMnCountRouter({ repo })); - const res = await request(app).get('/mnCount'); + const res = await request(app).get('/mncount'); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); @@ -36,7 +36,7 @@ describe('GET /mnCount', () => { repo.upsertByDate('2024-03-16', 2201, Date.parse('2024-03-16T00:00:05Z')); const app = mountApp(createMnCountRouter({ repo })); - const res = await request(app).get('/mnCount'); + const res = await request(app).get('/mncount'); expect(res.status).toBe(200); expect(res.body).toEqual([ { date: '2024-03-14', users: 2199 }, @@ -58,7 +58,7 @@ describe('GET /mnCount', () => { log: (level, event, meta) => logs.push({ level, event, meta }), }) ); - const res = await request(app).get('/mnCount'); + const res = await request(app).get('/mncount'); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'internal' }); expect(logs.some((l) => l.event === 'mncount_read_failed')).toBe(true); diff --git a/routes/mnList.js b/routes/mnList.js index 7295349..d53fd9f 100644 --- a/routes/mnList.js +++ b/routes/mnList.js @@ -1,13 +1,23 @@ const express = require("express"); const router = express.Router(); const { client, rpcServices } = require("../services/rpcClient"); +const securityLog = require("../lib/securityLog"); -router.get("/mnList", async (req, res) => { +router.get("/mnlist", async (req, res) => { try { const masternodes = await rpcServices(client.callRpc).masternode_list().call(); res.status(200).json(masternodes); } catch (err) { - res.status(500).json({ error: err.message }); + // Mirror routes/governance.js (govlist.rpc_failed). An RPC failure + // can leak internal hostnames/ports/stack-adjacent detail (e.g. + // "ECONNREFUSED 127.0.0.1:8370") via err.message, so log full + // detail server-side and return an opaque 500 to the client to + // match the error-shape used by the rest of the API. + securityLog.event('mnList.rpc_failed', { + req, + message: err && err.message, + }); + res.status(500).json({ error: 'internal' }); } }); diff --git a/routes/mnSearch.js b/routes/mnSearch.js index 90a1ea8..f1febd7 100644 --- a/routes/mnSearch.js +++ b/routes/mnSearch.js @@ -1,15 +1,28 @@ const express = require("express"); const moment = require("moment"); const router = express.Router(); -const { masternodesArr } = require("../data/dataStore"); -router.post("/mnSearch", (req, res) => { +// IMPORTANT: do NOT destructure `masternodesArr` at require time. The +// masternode tracker REASSIGNS `dataStore.masternodesArr = []` every +// 10 seconds (see services/masternodeTracker.js) and pushes into the +// fresh array, so a captured reference would forever see the original +// empty `[]` from data/dataStore.js. Read the property on every call +// to pick up whatever the tracker most recently published. The same +// reasoning is documented at server.js (`masternodesProvider`), which +// uses an arrow function for exactly this reason. +const dataStore = require("../data/dataStore"); + +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 masternodesArr = Array.isArray(dataStore.masternodesArr) + ? dataStore.masternodesArr + : []; + const filtered = masternodesArr .filter(mn => mn.address.split(":")[0].includes(query) || @@ -39,4 +52,4 @@ router.post("/mnSearch", (req, res) => { res.status(200).send({ returnArr: paginated, mnNumb: filtered.length }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/mnStats.js b/routes/mnStats.js index ab81467..ef3152e 100644 --- a/routes/mnStats.js +++ b/routes/mnStats.js @@ -3,7 +3,7 @@ const router = express.Router(); const calculations = require("../services/calculations"); const data = require("../data/dataStore"); -router.get("/mnStats", (req, res) => { +router.get("/mnstats", (req, res) => { res.status(200).send({ stats: calculations(), mapData: data.mapData, diff --git a/server.js b/server.js index 0b295ec..d33f9a1 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,6 @@ require('./services/masternodeTracker'); // Legacy public routes (no cookies, no credentials; stats + governance list // + masternode list etc. consumed by sysnode-info and third parties). const mnStatsRoute = require('./routes/mnStats'); -const masternodesRoute = require('./routes/masternodes'); const governanceRoute = require('./routes/governance'); const { createMnCountRouter } = require('./routes/mnCount'); const mnListRoute = require('./routes/mnList'); @@ -132,10 +131,10 @@ app.use((req, res, next) => { const dbPath = process.env.SYSNODE_DB_PATH || './data/sysnode.db'; const db = openDatabase(dbPath); -// Historical masternode-count store (feeds the /mnCount endpoint + +// Historical masternode-count store (feeds the /mncount endpoint + // the homepage TrendChart). We construct the repo up front because // three independent callers need it: the one-time seeder, the daily -// logger that appends new rows, and the /mnCount HTTP route. +// logger that appends new rows, and the /mncount HTTP route. // // seedMasternodeCount is idempotent: it loads the committed CSV // (db/seeds/masternode-count.csv) only when the table is empty, so @@ -362,7 +361,6 @@ mountAuthAndVault(app, { // registration exactly as it was before this PR. // ----------------------------------------------------------------------------- app.use(mnStatsRoute); -app.use(masternodesRoute); app.use(governanceRoute); app.use( createMnCountRouter({ diff --git a/services/mnCountLogger.test.js b/services/mnCountLogger.test.js index 1625230..93a4ab5 100644 --- a/services/mnCountLogger.test.js +++ b/services/mnCountLogger.test.js @@ -435,7 +435,7 @@ describe('createMnCountLogger', () => { // outside runAndReschedule()'s try/catch. A transient SQLite // read failure there would reject the returned promise, the // setTimeout callback didn't attach a .catch, and the scheduler - // silently died — daily /mnCount updates would halt until + // silently died — daily /mncount updates would halt until // process restart. This test wires a repo whose getLatestDate() // throws once on the first tick, then recovers, and asserts the // logger stays alive and rearms. diff --git a/tests/mnList.routes.test.js b/tests/mnList.routes.test.js new file mode 100644 index 0000000..13272db --- /dev/null +++ b/tests/mnList.routes.test.js @@ -0,0 +1,84 @@ +// Regression test for routes/mnlist.js error-shape hardening. +// +// Previously /mnlist returned `{ error: err.message }` on RPC failure, +// which can leak internal hostnames/ports/stack-adjacent detail (e.g. +// "ECONNREFUSED 127.0.0.1:8370"). The hardened handler now mirrors +// routes/governance.js (`govlist.rpc_failed`): +// +// - Logs the full error server-side via lib/securityLog. +// - Returns an opaque `{ error: 'internal' }` 500 to the client. +// +// Pin both halves of that contract. + +jest.mock('../services/rpcClient', () => { + const fakeCall = jest.fn(); + return { + client: { callRpc: jest.fn() }, + rpcServices: () => ({ + masternode_list: () => ({ call: fakeCall }), + }), + __fakeCall: fakeCall, + }; +}); + +jest.mock('../lib/securityLog', () => ({ + event: jest.fn(), +})); + +const express = require('express'); +const request = require('supertest'); + +const rpcClientMock = require('../services/rpcClient'); +const securityLogMock = require('../lib/securityLog'); +const mnListRoute = require('../routes/mnList'); + +function buildApp() { + const app = express(); + app.use(mnListRoute); + return app; +} + +describe('GET /mnlist', () => { + beforeEach(() => { + rpcClientMock.__fakeCall.mockReset(); + securityLogMock.event.mockReset(); + }); + + test('200 returns the RPC payload verbatim on success', async () => { + const payload = { 'aaaa-0': { address: '127.0.0.1', status: 'ENABLED' } }; + rpcClientMock.__fakeCall.mockResolvedValueOnce(payload); + + const res = await request(buildApp()).get('/mnlist'); + expect(res.status).toBe(200); + expect(res.body).toEqual(payload); + expect(securityLogMock.event).not.toHaveBeenCalled(); + }); + + test('500 returns opaque {error:"internal"} on RPC failure (no message leak)', async () => { + rpcClientMock.__fakeCall.mockRejectedValueOnce( + new Error('ECONNREFUSED 127.0.0.1:8370') + ); + + const res = await request(buildApp()).get('/mnlist'); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'internal' }); + // The leaky message must NOT appear in the response body. + expect(JSON.stringify(res.body)).not.toContain('ECONNREFUSED'); + expect(JSON.stringify(res.body)).not.toContain('127.0.0.1'); + }); + + test('logs RPC failures server-side with full message detail', async () => { + rpcClientMock.__fakeCall.mockRejectedValueOnce( + new Error('ECONNREFUSED 127.0.0.1:8370') + ); + + await request(buildApp()).get('/mnlist'); + expect(securityLogMock.event).toHaveBeenCalledTimes(1); + const [eventName, meta] = securityLogMock.event.mock.calls[0]; + expect(eventName).toBe('mnList.rpc_failed'); + expect(meta).toMatchObject({ + message: 'ECONNREFUSED 127.0.0.1:8370', + }); + expect(meta.req).toBeDefined(); + }); +}); diff --git a/tests/mnSearch.routes.test.js b/tests/mnSearch.routes.test.js new file mode 100644 index 0000000..901a8e5 --- /dev/null +++ b/tests/mnSearch.routes.test.js @@ -0,0 +1,140 @@ +// Regression test for the stale-reference bug in routes/mnsearch.js. +// +// The masternode tracker (services/masternodeTracker.js) reassigns +// `dataStore.masternodesArr = []` every 10 seconds and then pushes +// nodes into the fresh array. Previously the route did: +// +// const { masternodesArr } = require("../data/dataStore"); +// +// which destructures *at module-load time*, capturing the initial +// `[]` from data/dataStore.js. Subsequent tracker reassignments +// pointed dataStore.masternodesArr at a different array object, but +// the route handler still referenced the original empty one — so +// `/mnsearch` always returned `{ returnArr: [], mnNumb: 0 }` in +// production. +// +// The fix is to read the property on every call. These tests pin +// that behaviour: +// +// 1. The handler reflects updates published by the tracker AFTER +// the route module has been loaded. +// 2. Filtering by address/payee, sorting, and pagination still +// behave as documented. +// 3. A non-array dataStore.masternodesArr (defensive: tracker +// mid-write, RPC failure mid-cycle, etc.) does not throw. + +const express = require('express'); +const bodyParser = require('body-parser'); +const request = require('supertest'); + +const dataStore = require('../data/dataStore'); +const mnSearchRoute = require('../routes/mnSearch'); + +function buildApp() { + const app = express(); + app.use(bodyParser.json()); + app.use(mnSearchRoute); + return app; +} + +function makeNode(over = {}) { + return { + address: '127.0.0.1:18370', + payee: 'sys1qexamplepayeeaddr', + lastpaidtime: 0, + lastseen: Math.floor(Date.now() / 1000), + status: 'ENABLED', + ...over, + }; +} + +describe('POST /mnsearch — live dataStore read', () => { + // Save and restore the dataStore property so tests don't bleed state. + let savedArr; + beforeEach(() => { + savedArr = dataStore.masternodesArr; + }); + afterEach(() => { + dataStore.masternodesArr = savedArr; + }); + + test('returns nodes published by the tracker AFTER module load', async () => { + // Simulate the tracker's reassignment-then-push cycle. + dataStore.masternodesArr = []; + dataStore.masternodesArr.push( + makeNode({ address: '10.0.0.1:18370', payee: 'sys1qalpha' }), + makeNode({ address: '10.0.0.2:18370', payee: 'sys1qbeta' }) + ); + + const res = await request(buildApp()).post('/mnsearch').send({}); + expect(res.status).toBe(200); + expect(res.body.mnNumb).toBe(2); + expect(res.body.returnArr).toHaveLength(2); + }); + + test('reflects a tracker REASSIGNMENT (not just in-place mutation)', async () => { + // First cycle. + dataStore.masternodesArr = [makeNode({ payee: 'sys1qfirst' })]; + let res = await request(buildApp()).post('/mnsearch').send({}); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].payee).toBe('sys1qfirst'); + + // Tracker drops the array entirely and assigns a fresh one. This is + // the case the previous destructure-at-require-time code missed. + dataStore.masternodesArr = [ + makeNode({ payee: 'sys1qsecond' }), + makeNode({ payee: 'sys1qthird' }), + ]; + + res = await request(buildApp()).post('/mnsearch').send({}); + expect(res.body.mnNumb).toBe(2); + expect(res.body.returnArr.map(n => n.payee).sort()).toEqual([ + 'sys1qsecond', + 'sys1qthird', + ]); + }); + + test('filters by payee (case-insensitive substring)', async () => { + dataStore.masternodesArr = [ + makeNode({ address: '10.0.0.1:18370', payee: 'sys1qFooBar' }), + makeNode({ address: '10.0.0.2:18370', payee: 'sys1qBaz' }), + ]; + const res = await request(buildApp()) + .post('/mnsearch') + .send({ search: 'foo' }); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].payee).toBe('sys1qFooBar'); + }); + + test('filters by IP host (port stripped from query)', async () => { + dataStore.masternodesArr = [ + makeNode({ address: '203.0.113.7:18370', payee: 'sys1qa' }), + makeNode({ address: '198.51.100.4:18370', payee: 'sys1qb' }), + ]; + const res = await request(buildApp()) + .post('/mnsearch') + .send({ search: '203.0.113.7:18370' }); + expect(res.body.mnNumb).toBe(1); + expect(res.body.returnArr[0].address).toBe('203.0.113.7: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}` }) + ); + const res = await request(buildApp()) + .post('/mnsearch') + .send({ page: 2, perPage: 10 }); + expect(res.body.mnNumb).toBe(50); + expect(res.body.returnArr).toHaveLength(10); + }); + + test('survives a non-array masternodesArr without throwing', async () => { + // Defensive: the tracker briefly leaves the property in a transitional + // state on init, and an RPC failure path could theoretically wipe it. + dataStore.masternodesArr = null; + const res = await request(buildApp()).post('/mnsearch').send({}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ returnArr: [], mnNumb: 0 }); + }); +}); diff --git a/tests/server.smoke.test.js b/tests/server.smoke.test.js index 74c54d9..0c6ba9c 100644 --- a/tests/server.smoke.test.js +++ b/tests/server.smoke.test.js @@ -56,7 +56,7 @@ function buildSmokeApp() { app.get('/health', (_req, res) => res.json({ ok: true })); // Mimic a legacy route (without requiring a live RPC) to prove coexistence. - app.get('/mnStats', (_req, res) => res.json({ legacy: true })); + app.get('/mnstats', (_req, res) => res.json({ legacy: true })); return { app, db }; } @@ -74,9 +74,9 @@ describe('server smoke: legacy + auth/vault coexistence', () => { expect(res.body.ok).toBe(true); }); - test('GET /mnStats (legacy) has permissive CORS and responds', async () => { + test('GET /mnstats (legacy) has permissive CORS and responds', async () => { const res = await request(ctx.app) - .get('/mnStats') + .get('/mnstats') .set('Origin', 'https://anywhere.example'); expect(res.status).toBe(200); expect(res.headers['access-control-allow-origin']).toBe('*');