From 269b32b6e40f0fc89b247fb573b716d99950ddd3 Mon Sep 17 00:00:00 2001 From: Rhett Trappman Date: Wed, 2 Sep 2026 14:36:23 -0600 Subject: [PATCH 1/4] fix: use scoped rule queries and API rule IDs --- cli/src/commands/rules.js | 76 +++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/cli/src/commands/rules.js b/cli/src/commands/rules.js index 1e3cae8..0d801e0 100644 --- a/cli/src/commands/rules.js +++ b/cli/src/commands/rules.js @@ -1,13 +1,34 @@ const { getClient, resolveBoxGid } = require('../api/client'); +/** + * Build the documented box-scoped rule query. + */ +function buildRuleQuery(gid, query) { + const boxQuery = `box.id:${gid}`; + return query ? `${boxQuery} ${query}` : boxQuery; +} + /** * Fetch all rules for a box. */ -async function fetchAll(client, gid, apiParams = {}) { - const { data } = await client.get('/rules', { params: { gid, ...apiParams } }); +async function fetchAll(client, gid, query) { + const { data } = await client.get('/rules', { + params: { query: buildRuleQuery(gid, query) } + }); return Array.isArray(data) ? data : (data.results || []); } +/** + * Resolve an API rule ID within the selected box. + * + * Rule IDs are globally addressed UUIDs in the MSP API. The box GID is + * verified against the returned rule before the rule is used. + */ +function findRuleById(rules, id, gid) { + const ruleId = String(id); + return rules.find(r => r.id === ruleId && r.gid === gid); +} + /** * Client-side filtering applied after server fetch. */ @@ -55,10 +76,13 @@ const Rules = { const gid = await resolveBoxGid(options.box, options); const client = getClient(options); - const apiParams = {}; + let apiQuery; if (options.params) { try { - Object.assign(apiParams, JSON.parse(options.params)); + const parsedParams = JSON.parse(options.params); + if (parsedParams.query !== undefined) { + apiQuery = parsedParams.query; + } } catch { console.error(JSON.stringify({ error: 'Invalid --params JSON' })); process.exit(1); @@ -66,7 +90,7 @@ const Rules = { } try { - const all = await fetchAll(client, gid, apiParams); + const all = await fetchAll(client, gid, apiQuery); const filtered = applyFilters(all, options); console.log(JSON.stringify({ results: filtered, count: filtered.length }, null, 2)); } catch (err) { @@ -80,15 +104,13 @@ const Rules = { try { const all = await fetchAll(client, gid); - const numId = String(id); - - // Match by numeric rule ID (last segment of composite id "gid:num") - const rule = - all.find(r => r.id === `${gid}:${numId}`) || - all.find(r => r.id?.split(':').pop() === numId); + const rule = findRuleById(all, id, gid); if (!rule) { - console.error(JSON.stringify({ error: `Rule "${id}" not found.`, hint: 'Use fw rules list to see all rule IDs.' })); + console.error(JSON.stringify({ + error: `Rule "${id}" not found in selected box.`, + hint: 'Use fw rules list to see rule IDs.' + })); process.exit(1); } @@ -126,8 +148,20 @@ const Rules = { pause: async (id, options) => { const gid = await resolveBoxGid(options.box, options); const client = getClient(options); + try { - const { data } = await client.post(`/rules/${gid}:${id}/pause`, {}); + const all = await fetchAll(client, gid); + const rule = findRuleById(all, id, gid); + + if (!rule) { + console.error(JSON.stringify({ + error: `Rule "${id}" not found in selected box.`, + hint: 'Use fw rules list to see rule IDs.' + })); + process.exit(1); + } + + const { data } = await client.post(`/rules/${encodeURIComponent(rule.id)}/pause`, {}); console.log(JSON.stringify(data ?? { ok: true }, null, 2)); } catch (err) { console.error(JSON.stringify({ error: 'Pause failed', status: err.response?.status, details: err.response?.data || err.message })); @@ -137,8 +171,20 @@ const Rules = { resume: async (id, options) => { const gid = await resolveBoxGid(options.box, options); const client = getClient(options); + try { - const { data } = await client.post(`/rules/${gid}:${id}/resume`, {}); + const all = await fetchAll(client, gid); + const rule = findRuleById(all, id, gid); + + if (!rule) { + console.error(JSON.stringify({ + error: `Rule "${id}" not found in selected box.`, + hint: 'Use fw rules list to see rule IDs.' + })); + process.exit(1); + } + + const { data } = await client.post(`/rules/${encodeURIComponent(rule.id)}/resume`, {}); console.log(JSON.stringify(data ?? { ok: true }, null, 2)); } catch (err) { console.error(JSON.stringify({ error: 'Resume failed', status: err.response?.status, details: err.response?.data || err.message })); @@ -147,3 +193,5 @@ const Rules = { }; module.exports = Rules; +module.exports.buildRuleQuery = buildRuleQuery; +module.exports.findRuleById = findRuleById; From 409ee072c8f160dca5f0e0ca31e5108e6f575e97 Mon Sep 17 00:00:00 2001 From: Rhett Trappman Date: Wed, 2 Sep 2026 14:36:27 -0600 Subject: [PATCH 2/4] docs: use API rule ID terminology --- cli/src/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/index.js b/cli/src/index.js index 7174b8d..6ce497b 100644 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -153,7 +153,7 @@ rules rules .command('get ') - .description('Get a rule by its numeric ID') + .description('Get a rule by its API rule ID') .option('--box ', 'Box Name or GID') .action((id, options) => { Rules.get(id, { ...options, ...program.opts() }); @@ -161,7 +161,7 @@ rules rules .command('pause ') - .description('Pause a rule by its numeric ID (requires API support)') + .description('Pause a rule by its API rule ID (requires API support)') .option('--box ', 'Box Name or GID') .action((id, options) => { Rules.pause(id, { ...options, ...program.opts() }); @@ -169,7 +169,7 @@ rules rules .command('resume ') - .description('Resume a paused rule by its numeric ID (requires API support)') + .description('Resume a paused rule by its API rule ID (requires API support)') .option('--box ', 'Box Name or GID') .action((id, options) => { Rules.resume(id, { ...options, ...program.opts() }); From 501532697abcf2230858485d75b28a51f0241cd9 Mon Sep 17 00:00:00 2001 From: Rhett Trappman Date: Wed, 2 Sep 2026 14:36:33 -0600 Subject: [PATCH 3/4] test: verify rule box scoping and ID handling --- test/rules.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/rules.test.js diff --git a/test/rules.test.js b/test/rules.test.js new file mode 100644 index 0000000..47856bf --- /dev/null +++ b/test/rules.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildRuleQuery, findRuleById } = require('../cli/src/commands/rules'); + +test('scopes rule collection queries to the selected box', () => { + assert.equal(buildRuleQuery('box-a'), 'box.id:box-a'); + assert.equal( + buildRuleQuery('box-a', 'status:active'), + 'box.id:box-a status:active' + ); +}); + +test('does not allow a caller query to replace the selected box scope', () => { + assert.equal( + buildRuleQuery('box-a', 'box.id:box-b'), + 'box.id:box-a box.id:box-b' + ); +}); + +test('resolves only the exact API rule ID within the selected box', () => { + const rules = [ + { id: 'rule-a', gid: 'box-a' }, + { id: 'rule-b', gid: 'box-b' }, + ]; + + assert.deepEqual(findRuleById(rules, 'rule-a', 'box-a'), rules[0]); + assert.equal(findRuleById(rules, 'rule-b', 'box-a'), undefined); +}); + +test('does not treat numeric ID suffixes or composite IDs as API rule IDs', () => { + const rules = [ + { id: 'rule-a', gid: 'box-a' }, + { id: 'box-b:42', gid: 'box-b' }, + ]; + + assert.equal(findRuleById(rules, '42', 'box-a'), undefined); + assert.equal(findRuleById(rules, 'box-b:42', 'box-a'), undefined); +}); From add307612467d7b34b4a13b8c5757ba7c92b2f0f Mon Sep 17 00:00:00 2001 From: Rhett Trappman Date: Wed, 2 Sep 2026 14:36:39 -0600 Subject: [PATCH 4/4] test: enable Node test runner --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6bbf6ac..2de1b1c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "fw": "./src/index.js" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "dependencies": { "axios": "^1.6.0",