diff --git a/tests/unit/handlers/actions.handlers.test.js b/tests/unit/handlers/actions.handlers.test.js index a0e9cd6..45e4681 100644 --- a/tests/unit/handlers/actions.handlers.test.js +++ b/tests/unit/handlers/actions.handlers.test.js @@ -11,6 +11,7 @@ const { voteAction, cancelActionsBatch } = require('../../../workers/lib/server/handlers/actions.handlers') +const { APPROVED_POOL_URLS } = require('../../../workers/lib/constants') const { createMockCtxWithOrks, createMockReq, withDataProxy } = require('../helpers/mockHelpers') test('queryActionsBatch - basic functionality', async (t) => { @@ -257,6 +258,412 @@ test('pushAction - with valid permissions', async (t) => { t.pass() }) +for (const action of ['registerConfig', 'updateConfig']) { + test(`pushAction - ${action} resolves poolUrls to approved pool url/worker settings`, async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { + orks: [ + { rpcPublicKey: 'key1' } + ] + }, + authLib: { + getTokenPerms: async () => ({ + write: true, + permissions: ['actions:write'] + }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + if (method === 'getGlobalConfig') { + return { approvedPoolUrls: APPROVED_POOL_URLS } + } + capturedPayload = payload + return { id: 'new-action', success: true } + } + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ + name: 'my-config', + data: { + poolUrls: APPROVED_POOL_URLS.map((config) => ({ + poolUrlId: config.id, + workerName: `${config.id}-worker`, + workerPassword: 'x' + })) + } + }] + } + } + + const result = await pushAction(mockCtx, mockReq) + + t.ok(Array.isArray(result), 'should return array') + t.ok(result[0].id === 'new-action', 'should return new action id') + + const [poolConfig] = capturedPayload.params + t.is(poolConfig.data.poolUrls.length, APPROVED_POOL_URLS.length, 'should resolve one entry per approved pool') + poolConfig.data.poolUrls.forEach((resolved, i) => { + const approved = APPROVED_POOL_URLS[i] + t.is(resolved.poolUrlId, approved.id, 'poolUrlId should be preserved on the resolved entry') + t.is(resolved.url, `stratum+tcp://${approved.host}:${approved.port}`, 'url should be built from host and port') + t.is(resolved.pool, approved.name, 'pool should be the approved config name') + t.is(resolved.workerName, `${approved.id}-worker`, 'workerName should pass through from the request') + t.is(resolved.workerPassword, 'x', 'workerPassword should pass through from the request') + }) + + t.pass() + }) + + test(`pushAction - ${action} disregards a url sent from the client`, async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: ['actions:write'] }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + if (method === 'getGlobalConfig') { + return { approvedPoolUrls: APPROVED_POOL_URLS } + } + capturedPayload = payload + return { id: 'new-action', success: true } + } + } + }) + + const approved = APPROVED_POOL_URLS[0] + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ + name: 'my-config', + data: { + poolUrls: [{ + poolUrlId: approved.id, + url: 'stratum+tcp://attacker-controlled.example.com:9999', + workerName: 'worker1', + workerPassword: 'secret' + }] + } + }] + } + } + + await pushAction(mockCtx, mockReq) + + const [poolConfig] = capturedPayload.params + const [resolved] = poolConfig.data.poolUrls + t.is(resolved.url, `stratum+tcp://${approved.host}:${approved.port}`, 'url should be rebuilt from the approved pool config, not the client-supplied url') + t.not(resolved.url, 'stratum+tcp://attacker-controlled.example.com:9999', 'client-supplied url should never reach the payload') + + t.pass() + }) + + test(`pushAction - ${action} fetches approved pool urls from ork global config`, async (t) => { + let getGlobalConfigCalls = 0 + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: ['actions:write'] }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + if (method === 'getGlobalConfig') { + getGlobalConfigCalls++ + return { approvedPoolUrls: APPROVED_POOL_URLS } + } + return { id: 'new-action', success: true } + } + } + }) + + const approved = APPROVED_POOL_URLS[0] + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ + name: 'my-config', + data: { poolUrls: [{ poolUrlId: approved.id, workerName: 'worker1', workerPassword: 'x' }] } + }] + } + } + + await pushAction(mockCtx, mockReq) + + t.ok(getGlobalConfigCalls > 0, 'should fetch global config from the orks instead of a static constant') + + t.pass() + }) + + test(`pushAction - ${action} throws for unknown poolUrlId when no ork reports approvedPoolUrls`, async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: ['actions:write'] }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + if (method === 'getGlobalConfig') { + return {} + } + return { id: 'new-action', success: true } + } + } + }) + + const approved = APPROVED_POOL_URLS[0] + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ + name: 'my-config', + data: { poolUrls: [{ poolUrlId: approved.id, workerName: 'worker1', workerPassword: 'x' }] } + }] + } + } + + try { + await pushAction(mockCtx, mockReq) + t.fail('should throw error when no ork reports approved pool urls') + } catch (err) { + t.is(err.message, 'ERR_INVALID_POOL_URL_ID_INVALID', 'should throw ERR_INVALID_POOL_URL_ID_INVALID') + } + + t.pass() + }) + + test(`pushAction - ${action} resolves poolUrls when only one ork reports approvedPoolUrls`, async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { + orks: [ + { rpcPublicKey: 'key1' }, + { rpcPublicKey: 'key2' } + ] + }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: ['actions:write'] }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + if (method === 'getGlobalConfig') { + return key === 'key1' ? { approvedPoolUrls: APPROVED_POOL_URLS } : {} + } + capturedPayload = payload + return { id: 'new-action', success: true } + } + } + }) + + const approved = APPROVED_POOL_URLS[0] + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ + name: 'my-config', + data: { poolUrls: [{ poolUrlId: approved.id, workerName: 'worker1', workerPassword: 'x' }] } + }] + } + } + + await pushAction(mockCtx, mockReq) + + const [poolConfig] = capturedPayload.params + const [resolved] = poolConfig.data.poolUrls + t.is(resolved.url, `stratum+tcp://${approved.host}:${approved.port}`, 'should resolve using the ork that reported approvedPoolUrls') + + t.pass() + }) + + test(`pushAction - ${action} throws for missing/invalid poolUrls`, async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: [] }) + }, + net_r0: { + jRequest: async () => ({ id: 'new-action', success: true }) + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ name: 'my-config', data: {} }] + } + } + + try { + await pushAction(mockCtx, mockReq) + t.fail('should throw error for missing poolUrls') + } catch (err) { + t.is(err.message, 'ERR_INVALID_POOL_URLS', 'should throw ERR_INVALID_POOL_URLS') + } + + t.pass() + }) + + test(`pushAction - ${action} throws when a poolUrl entry is missing poolUrlId`, async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: [] }) + }, + net_r0: { + jRequest: async () => ({ id: 'new-action', success: true }) + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ name: 'my-config', data: { poolUrls: [{ workerName: 'worker1' }] } }] + } + } + + try { + await pushAction(mockCtx, mockReq) + t.fail('should throw error for missing poolUrlId') + } catch (err) { + t.is(err.message, 'ERR_INVALID_POOL_URL_ID_MISSING', 'should throw ERR_INVALID_POOL_URL_ID_MISSING') + } + + t.pass() + }) + + test(`pushAction - ${action} throws for unknown poolUrlId`, async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: [] }) + }, + net_r0: { + jRequest: async () => ({ id: 'new-action', success: true }) + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [{ name: 'my-config', data: { poolUrls: [{ poolUrlId: 'does-not-exist' }] } }] + } + } + + try { + await pushAction(mockCtx, mockReq) + t.fail('should throw error for unknown poolUrlId') + } catch (err) { + t.is(err.message, 'ERR_INVALID_POOL_URL_ID_INVALID', 'should throw ERR_INVALID_POOL_URL_ID_INVALID') + } + + t.pass() + }) + + test(`pushAction - ${action} with no pool config passes params through unchanged`, async (t) => { + let capturedPayload = null + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: [] }) + }, + net_r0: { + jRequest: async (key, method, payload, opts) => { + capturedPayload = payload + return { id: 'new-action', success: true } + } + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: [] + } + } + + const result = await pushAction(mockCtx, mockReq) + + t.ok(Array.isArray(result), 'should return array') + t.alike(capturedPayload.params, [], 'params should pass through unchanged when no pool config present') + + t.pass() + }) + + test(`pushAction - ${action} throws ERR_INVALID_PAYLOAD when params is not an array`, async (t) => { + const mockCtx = withDataProxy({ + conf: { orks: [{ rpcPublicKey: 'key1' }] }, + authLib: { + getTokenPerms: async () => ({ write: true, permissions: [] }) + }, + net_r0: { + jRequest: async () => ({ id: 'new-action', success: true }) + } + }) + + const mockReq = { + _info: { + authToken: 'token123', + user: { metadata: { email: 'test@example.com' } } + }, + body: { + action, + params: { name: 'my-config' } + } + } + + try { + await pushAction(mockCtx, mockReq) + t.fail('should throw error for non-array params') + } catch (err) { + t.is(err.message, 'ERR_INVALID_PAYLOAD', 'should throw ERR_INVALID_PAYLOAD') + } + + t.pass() + }) +} + test('pushActionsBatch - requires write permission', async (t) => { const mockCtx = { authLib: { diff --git a/workers/lib/server/handlers/actions.handlers.js b/workers/lib/server/handlers/actions.handlers.js index ea5d8fa..91fd763 100644 --- a/workers/lib/server/handlers/actions.handlers.js +++ b/workers/lib/server/handlers/actions.handlers.js @@ -78,6 +78,64 @@ async function pushActionsBatch (ctx, req, rep) { }) } +const transformPushActionPayload = async (ctx, payload) => { + switch (payload.action) { + case 'registerConfig': + case 'updateConfig': { + if (!payload || !Array.isArray(payload.params)) { + throw new Error('ERR_INVALID_PAYLOAD') + } + + const [poolConfig] = payload.params + if (!poolConfig) return payload + + const { poolUrls } = poolConfig.data ?? {} + if (!poolUrls || !Array.isArray(poolUrls)) throw new Error('ERR_INVALID_POOL_URLS') + delete poolConfig.data.poolUrls + + const result = [] + for (const poolUrlSetting of poolUrls) { + const { + poolUrlId, workerName, workerPassword + } = poolUrlSetting + + if (!poolUrlId) { + throw new Error('ERR_INVALID_POOL_URL_ID_MISSING') + } + + let approvedPoolUrls = [] + const orkGlobalConfigResults = await ctx.dataProxy.requestDataMap('getGlobalConfig', {}) + for (const orkResult of orkGlobalConfigResults) { + if (!orkResult || typeof orkResult !== 'object') continue + if (orkResult.approvedPoolUrls) { + approvedPoolUrls = orkResult.approvedPoolUrls + } + } + + const poolUrl = approvedPoolUrls.find(config => config.id === poolUrlId) + if (!poolUrl) { + throw new Error('ERR_INVALID_POOL_URL_ID_INVALID') + } + + const { host, port, name } = poolUrl + result.push({ + poolUrlId, + url: `stratum+tcp://${host}:${port}`, + workerName, + workerPassword, + pool: name + }) + } + + poolConfig.data.poolUrls = result + return payload + } + + default: + return payload + } +} + async function pushAction (ctx, req) { const { write, permissions } = await ctx.authLib.getTokenPerms(req._info.authToken) if (!write) { @@ -92,7 +150,9 @@ async function pushAction (ctx, req) { authPerms: permissions } - return await ctx.dataProxy.requestData('pushAction', payload, (res, resultsArray) => { + const transformedPayload = await transformPushActionPayload(ctx, structuredClone(payload)) + + return await ctx.dataProxy.requestData('pushAction', transformedPayload, (res, resultsArray) => { if (res.error) { resultsArray.push({ id: null, errors: [res.error] }) } else {