Skip to content
Open
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
76 changes: 62 additions & 14 deletions cli/src/commands/rules.js
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -55,18 +76,21 @@ 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);
}
}

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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 }));
Expand All @@ -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 }));
Expand All @@ -147,3 +193,5 @@ const Rules = {
};

module.exports = Rules;
module.exports.buildRuleQuery = buildRuleQuery;
module.exports.findRuleById = findRuleById;
6 changes: 3 additions & 3 deletions cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,23 +153,23 @@ rules

rules
.command('get <id>')
.description('Get a rule by its numeric ID')
.description('Get a rule by its API rule ID')
.option('--box <name|gid>', 'Box Name or GID')
.action((id, options) => {
Rules.get(id, { ...options, ...program.opts() });
});

rules
.command('pause <id>')
.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 <name|gid>', 'Box Name or GID')
.action((id, options) => {
Rules.pause(id, { ...options, ...program.opts() });
});

rules
.command('resume <id>')
.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 <name|gid>', 'Box Name or GID')
.action((id, options) => {
Rules.resume(id, { ...options, ...program.opts() });
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions test/rules.test.js
Original file line number Diff line number Diff line change
@@ -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);
});