From 115ecec1a2bfc12a11314f1e32508469a7abe145 Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Fri, 2 Oct 2026 13:46:15 +1000 Subject: [PATCH] Add quant rules for code-managed edge rules (6.3.0) - quant rules deploys edge-rules.json through the portal rules API with a separate project-scoped token (QUANT_API_TOKEN, QUANT_BASE_URL); it never uses the content token or upload endpoint and does not follow redirects. - Function ids give stable per-project function UUIDs, so rules can reference functions by function_ref; duplicate ids fail before upload. - Managed rules: a 409 lists each rule that changed outside code (field names, values only for matchers and weight) or collides; --force overwrites drift and never takes over a rule the manifest did not create. - Tests run with a temporary home so they never overwrite saved credentials. --- .mocharc.json | 5 +- README.md | 47 ++++++++- cli.js | 3 +- package-lock.json | 4 +- package.json | 2 +- src/commandLoader.js | 3 + src/commands/functions.js | 15 ++- src/commands/rules.js | 134 +++++++++++++++++++++++++ src/helper/function-identity.js | 6 ++ tests/setup.mjs | 16 +++ tests/unit/commands/functions.test.mjs | 19 ++++ tests/unit/commands/rules.test.mjs | 78 ++++++++++++++ 12 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 src/commands/rules.js create mode 100644 src/helper/function-identity.js create mode 100644 tests/unit/commands/rules.test.mjs diff --git a/.mocharc.json b/.mocharc.json index 49de95e..6021f05 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -4,5 +4,8 @@ "node-option": [ "experimental-vm-modules", "no-warnings" + ], + "require": [ + "./tests/setup.mjs" ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index a20511d..e5f603c 100644 --- a/README.md +++ b/README.md @@ -224,4 +224,49 @@ npm run test ## Contributing -Issues and feature requests are managed via Github and pull requests are welcomed. \ No newline at end of file +Issues and feature requests are managed via Github and pull requests are welcomed. + +### Deploy edge rules with a project-scoped portal token + +```sh +# QUANT_API_TOKEN: separate project-scoped token with projects:read, +# rules:read and rules:write. QUANT_BASE_URL: the owning portal's /api/v2 URL. +quant rules edge-rules.json --functions edge-functions.json -c CUSTOMER -p PROJECT --dry-run +quant functions edge-functions.json -c CUSTOMER -p PROJECT +quant rules edge-rules.json --functions edge-functions.json -c CUSTOMER -p PROJECT +``` + +`quant rules` never uses your content write token or upload endpoint. It requires +an explicit portal URL and will not follow redirects. `--dry-run` validates +permissions and previews changes without writing rules. + +Rules JSON uses `version: 1`, a stable `namespace`, and a `rules` array. Each rule +has a stable `id`, `type` (`function`, `auth`, or `filter`), `urls`, an explicit +numeric `weight` (lower runs first), and either `function_ref` or `function_uuid`. +Optional fields are `name`, `domains`, `methods`, and `disabled`. +`function_ref` resolves an `id` in the functions manifest, with matching type. +Functions may provide an explicit UUID; otherwise an `id` produces a stable UUID +per customer and project. Existing manifests with UUIDs remain supported. + +```json +{ + "version": 1, + "namespace": "orbit", + "rules": [{"id":"api","type":"function","urls":["/api/*"],"weight":10,"function_ref":"orbit-api-v1"}] +} +``` + +Deploys update only entries with matching namespace/ID and preserve other rules. +Omitted entries are preserved; disable a rule explicitly with `disabled: true`. +Place authentication rules before the functions they protect using lower weights; +weights from -100000 to 100000 are allowed, and a negative weight runs before any +rule added in the dashboard or API. + +Rules deployed this way are managed in code: the dashboard and the rules API show +them read-only. If one was changed outside code anyway, the deploy writes nothing, +exits non-zero and lists each changed field (values only for matchers and weight). +Re-run with `--force` to overwrite those rules with the manifest. `--force` never +takes over a rule this manifest did not create (a rule ID collision). + +Portal-issued template tokens expire after one year; rotate the token and update +`QUANT_API_TOKEN` before expiry. diff --git a/cli.js b/cli.js index ebfd66d..ebfcd27 100755 --- a/cli.js +++ b/cli.js @@ -136,7 +136,8 @@ function cliMode() { builder: command.builder, handler: async (argv) => { try { - await showActiveConfig(); + // Rules have a separate portal endpoint and credential. + if (_name !== 'rules') await showActiveConfig(); const result = await command.handler(argv); if (result) { console.log(result); diff --git a/package-lock.json b/package-lock.json index ab7df7e..ac8979d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quantcdn/quant-cli", - "version": "6.2.0", + "version": "6.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quantcdn/quant-cli", - "version": "6.2.0", + "version": "6.3.0", "license": "ISC", "dependencies": { "@clack/core": "^1.0.0", diff --git a/package.json b/package.json index 71dae6e..bd969a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quantcdn/quant-cli", - "version": "6.2.0", + "version": "6.3.0", "description": "Deploy tools for QuantCDN", "type": "module", "main": "cli.js", diff --git a/src/commandLoader.js b/src/commandLoader.js index d47f826..7549eeb 100644 --- a/src/commandLoader.js +++ b/src/commandLoader.js @@ -10,6 +10,7 @@ import functionCommand from './commands/function.js'; import filterCommand from './commands/function_filter.js'; import authCommand from './commands/function_auth.js'; import functionsCommand from './commands/functions.js'; +import rulesCommand from './commands/rules.js'; import unpublishCommand from './commands/unpublish.js'; import deleteCommand from './commands/delete.js'; import infoCommand from './commands/info.js'; @@ -33,6 +34,7 @@ export function loadCommands() { 'filter': filterCommand, 'auth': authCommand, 'functions': functionsCommand, + 'rules': rulesCommand, // Destructive operations 'unpublish': unpublishCommand, @@ -67,6 +69,7 @@ export function getCommandOptions() { { value: 'filter', label: 'Deploy an edge filter' }, { value: 'auth', label: 'Deploy an edge auth function' }, { value: 'functions', label: 'Deploy multiple edge functions from JSON' }, + { value: 'rules', label: 'Deploy managed rules from JSON' }, // Visual separator { value: 'separator2', label: '───────────────────────', disabled: true }, diff --git a/src/commands/functions.js b/src/commands/functions.js index d4eb495..58e6e29 100644 --- a/src/commands/functions.js +++ b/src/commands/functions.js @@ -5,6 +5,7 @@ * quant functions */ import fs from 'fs'; +import { functionUuid } from '../helper/function-identity.js'; import config from '../config.js'; import client from '../quant-client.js'; import color from 'picocolors'; @@ -49,9 +50,21 @@ const command = { throw new Error(`Failed to read functions file: ${err.message}`); } + // Validate identities before uploading anything; duplicate IDs would overwrite + // the same function and make declarative rule references ambiguous. + if (!Array.isArray(functions)) throw new Error('Functions manifest must be an array'); + const ids = new Set(); + for (const func of functions) { + if (func.id === undefined) continue; + functionUuid(context.config.get('clientid'), context.config.get('project'), func.id); + if (ids.has(func.id)) throw new Error(`Duplicate function id: ${func.id}`); + ids.add(func.id); + } + // Process each function for (const func of functions) { - const { type, path, description, uuid } = func; + const { type, path, description } = func; + const uuid = func.uuid || (func.id ? functionUuid(context.config.get('clientid'), context.config.get('project'), func.id) : undefined); // Validate required fields if (!type || !path || !description) { diff --git a/src/commands/rules.js b/src/commands/rules.js new file mode 100644 index 0000000..8a0412b --- /dev/null +++ b/src/commands/rules.js @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import axios from 'axios'; +import { text, isCancel } from '@clack/prompts'; +import { functionUuid } from '../helper/function-identity.js'; + +export function resolveRules(manifest, functions, customer, project) { + if (!manifest || manifest.version !== 1 || !/^[a-z][a-z0-9-]{0,47}$/.test(manifest.namespace) || !Array.isArray(manifest.rules)) { + throw new Error('Rules manifest requires version: 1, namespace and a rules array'); + } + if (Buffer.byteLength(JSON.stringify(manifest)) > 65536 || manifest.rules.length > 100) throw new Error('Rules manifest exceeds size limits'); + const ids = new Map(); + for (const fn of functions) { + if (!fn.id) continue; + if (ids.has(fn.id)) throw new Error(`Duplicate function id: ${fn.id}`); + ids.set(fn.id, fn); + } + const seen = new Set(); + return { ...manifest, rules: manifest.rules.map(rule => { + if (!rule || !/^[a-z][a-z0-9-]{0,47}$/.test(rule.id) || seen.has(rule.id)) throw new Error('Rules require unique lowercase ids'); + seen.add(rule.id); + if (!['function', 'auth', 'filter'].includes(rule.type)) throw new Error(`Invalid rule type: ${rule.type}`); + const result = { ...rule }; + if (rule.function_ref !== undefined) { + if (rule.function_uuid !== undefined) throw new Error('Use function_ref or function_uuid, not both'); + const fn = ids.get(rule.function_ref); + if (!fn) throw new Error(`Unknown function reference: ${rule.function_ref}`); + const type = fn.type === 'edge' ? 'function' : fn.type; + if (type !== rule.type) throw new Error(`Function type does not match rule: ${rule.id}`); + result.function_uuid = fn.uuid || functionUuid(customer, project, fn.id); + delete result.function_ref; + } + if (!/^[a-f\d]{8}-[a-f\d]{4}-[1-5][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(result.function_uuid || '')) throw new Error(`Invalid function UUID for rule: ${rule.id}`); + return result; + }) }; +} + +export function rulesOptions(args, env = process.env) { + let saved = {}; + try { saved = JSON.parse(fs.readFileSync('quant.json', 'utf8')); } catch { /* Optional project selection only. */ } + const customer = args.clientid || args.c || env.QUANT_CLIENT_ID || env.QUANT_CUSTOMER || saved.clientid; + const project = args.project || args.p || env.QUANT_PROJECT || saved.project; + // Deliberately never read QUANT_TOKEN, -t, saved.token, or upload endpoint. + const token = args['api-token'] || env.QUANT_API_TOKEN; + const base = args['api-base-url'] || env.QUANT_BASE_URL; + if (!customer || !project || !token || !base) throw new Error('Rules require customer, project, QUANT_API_TOKEN and QUANT_BASE_URL (portal /api/v2 URL)'); + const url = new URL(base); + if (url.username || url.password || url.search || url.hash || !/\/api\/v2\/?$/.test(url.pathname) || + (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) { + throw new Error('Rules require an HTTPS portal /api/v2 URL (HTTP is allowed only for localhost)'); + } + return { customer, project, token, base: base.replace(/\/$/, '') }; +} + +// Drift values printed in CI logs: matchers and placement only. Anything else +// (action_config in particular) can hold credentials, so only its name is shown. +const SAFE_DIFF_FIELDS = ['name', 'disabled', 'url', 'domain', 'method', 'method_is', 'weight', 'action', 'country', 'ip']; +const STATUSES = ['created', 'updated', 'unchanged', 'forced', 'drift', 'collision']; + +function deployParams(args) { + const params = {}; + if (args['dry-run']) params.dry_run = 1; + if (args.force) params.force = 1; + return params; +} + +function shortJson(value) { + const json = JSON.stringify(value) ?? 'null'; + return json.length > 200 ? `${json.slice(0, 197)}...` : json; +} + +function describeRefusal(rule) { + const id = /^[a-z][a-z0-9-]{0,47}$/.test(rule?.id) ? rule.id : '(unknown rule)'; + const status = STATUSES.includes(rule?.status) ? rule.status : 'unknown'; + const lines = [`${id}: ${status}`]; + for (const [field, change] of Object.entries(status === 'drift' ? rule.diff || {} : {})) { + if (!/^[a-z_]{1,64}$/.test(field)) continue; + lines.push(SAFE_DIFF_FIELDS.includes(field) ? ` ${field}: live ${shortJson(change?.live)} -> code ${shortJson(change?.code)}` : ` ${field}: changed`); + } + return lines.join('\n'); +} + +function refusalError(rules) { + for (const rule of rules) console.log(describeRefusal(rule)); + if (rules.some(rule => rule?.status === 'collision')) { + return new Error('A rule id collides with a rule this manifest does not own; nothing was written. Rename the rule id or remove the other rule (--force never takes over a rule).'); + } + return new Error('Managed rules were changed outside code; nothing was written. Re-run with --force to overwrite them with this manifest.'); +} + +// Never print Axios config/headers or an arbitrary upstream body containing credentials. +function deployError(error) { + const status = error.response?.status; + if (status === 409 && Array.isArray(error.response?.data?.rules)) return refusalError(error.response.data.rules); + if (status === 409) return new Error('Rules are being changed by another request (409). Retry shortly.'); + return new Error(`Rules deployment failed (${status || error.code || 'network error'}). Check portal URL, token scopes and project access.`); +} + +export default { + command: 'rules ', + describe: 'Deploy managed rules with a separate project-scoped portal API token', + builder: yargs => yargs.positional('file', { type: 'string', describe: 'Rules JSON manifest' }) + .option('functions', { type: 'string', describe: 'Functions manifest for resolving function_ref' }) + .option('api-token', { type: 'string', describe: 'Scoped portal token (prefer QUANT_API_TOKEN)' }) + .option('api-base-url', { type: 'string', describe: 'Portal /api/v2 URL (or QUANT_BASE_URL)' }) + .option('dry-run', { type: 'boolean', default: false, describe: 'Validate permissions and preview changes without saving' }) + .option('force', { type: 'boolean', default: false, describe: 'Overwrite managed rules that were changed outside code (never takes over a rule this manifest does not own)' }), + promptArgs: async () => { + const file = await text({ message: 'Path to rules manifest', placeholder: 'edge-rules.json' }); + return isCancel(file) ? null : { file }; + }, + async handler(args) { + const options = rulesOptions(args); + const manifest = JSON.parse(fs.readFileSync(args.file, 'utf8')); + const functions = args.functions ? JSON.parse(fs.readFileSync(args.functions, 'utf8')) : []; + if (!Array.isArray(functions)) throw new Error('Functions manifest must be an array'); + const body = resolveRules(manifest, functions, options.customer, options.project); + const url = `${options.base}/organizations/${encodeURIComponent(options.customer)}/projects/${encodeURIComponent(options.project)}/rules/deploy`; + let response; + try { + response = await axios.post(url, body, { + headers: { Authorization: `Bearer ${options.token}`, Accept: 'application/json' }, + params: deployParams(args), timeout: 60000, maxRedirects: 0 + }); + } catch (error) { + throw deployError(error); + } + if (!Array.isArray(response.data?.rules) || response.data.namespace !== manifest.namespace || response.data.rules.length !== manifest.rules.length || + response.data.rules.some((rule, i) => rule.id !== manifest.rules[i].id || !['created', 'updated', 'unchanged', 'forced'].includes(rule.status))) { + throw new Error('Rules API returned an invalid acknowledgement'); + } + for (const rule of response.data.rules) console.log(`${rule.id}: ${rule.status}`); + return args['dry-run'] ? 'Rules validated; no changes saved' : 'Rules deployed successfully'; + } +}; diff --git a/src/helper/function-identity.js b/src/helper/function-identity.js new file mode 100644 index 0000000..1f7ceed --- /dev/null +++ b/src/helper/function-identity.js @@ -0,0 +1,6 @@ +import { createHash } from 'node:crypto'; +export function functionUuid(customer, project, id) { + if (!/^[a-z][a-z0-9-]{0,47}$/.test(id)) throw new Error('Function id must be a lowercase slug (1–48 characters)'); + const hex = createHash('sha256').update(JSON.stringify([customer, project, id])).digest('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} diff --git a/tests/setup.mjs b/tests/setup.mjs index c6b1f97..1bcd1db 100644 --- a/tests/setup.mjs +++ b/tests/setup.mjs @@ -9,3 +9,19 @@ use(sinonChai); global.expect = expect; global.assert = assert; global.sinon = sinon; + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const originalHome = os.homedir; +const testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quant-cli-tests-')); +// config.save() writes a user-level file as well as quant.json. Tests must +// never overwrite a developer's saved credentials. +os.homedir = () => testHome; +export const mochaHooks = { + afterAll() { + os.homedir = originalHome; + fs.rmSync(testHome, { recursive: true, force: true }); + } +}; diff --git a/tests/unit/commands/functions.test.mjs b/tests/unit/commands/functions.test.mjs index 80221e7..2e4f93d 100644 --- a/tests/unit/commands/functions.test.mjs +++ b/tests/unit/commands/functions.test.mjs @@ -1,5 +1,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; +import { functionUuid } from '../../../src/helper/function-identity.js'; import _fs from 'fs'; import _path from 'path'; import mockClient from '../../mocks/quant-client.mjs'; @@ -46,6 +47,24 @@ describe('Functions Command', () => { }); describe('handler', () => { + it('uses the same stable identity as rules references when uploading', async () => { + readFileSync.returns(JSON.stringify([{ id: 'api', type: 'function', path: './api.js', description: 'API' }])); + const edgeFunction = sinon.stub().resolves({}); + await functions.handler.call({ config: mockConfig, client: () => ({ edgeFunction }) }, { file: 'functions.json' }); + expect(edgeFunction.firstCall.args[2]).to.equal(functionUuid('test-client', 'test-project', 'api')); + }); + + it('rejects duplicate function ids before the first upload', async () => { + const fn = { id: 'api', type: 'function', path: './api.js', description: 'API' }; + readFileSync.returns(JSON.stringify([fn, fn])); + const edgeFunction = sinon.stub(); + try { + await functions.handler.call({ config: mockConfig, client: () => ({ edgeFunction }) }, { file: 'functions.json' }); + expect.fail('duplicate must fail'); + } catch (error) { expect(error.message).to.include('Duplicate function id'); } + expect(edgeFunction.called).to.equal(false); + }); + it('should deploy auth functions', async () => { const mockJson = [{ type: 'auth', diff --git a/tests/unit/commands/rules.test.mjs b/tests/unit/commands/rules.test.mjs new file mode 100644 index 0000000..3b3261a --- /dev/null +++ b/tests/unit/commands/rules.test.mjs @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import fs from 'node:fs'; +import axios from 'axios'; +import rules, { resolveRules, rulesOptions } from '../../../src/commands/rules.js'; +import { functionUuid } from '../../../src/helper/function-identity.js'; +const manifest = () => ({ version: 1, namespace: 'orbit', rules: [{ id: 'api', type: 'function', urls: ['/api/*'], weight: 0, function_ref: 'orbit-api-v1' }] }); +const functions = [{ id: 'orbit-api-v1', type: 'function', path: 'api.js' }]; +describe('Rules deployment', () => { + afterEach(() => sinon.restore()); + it('resolves stable per-project function references', () => { + const result = resolveRules(manifest(), functions, 'org', 'site'); + expect(result.rules[0].function_uuid).to.equal(functionUuid('org', 'site', 'orbit-api-v1')); + expect(result.rules[0]).not.to.have.property('function_ref'); + expect(result.rules[0].function_uuid).not.to.equal(functionUuid('org', 'other', 'orbit-api-v1')); + }); + it('rejects unknown references, duplicate ids and auth/function mismatches', () => { + expect(() => resolveRules(manifest(), [], 'org', 'site')).to.throw('Unknown'); + const m = manifest(); m.rules.push(m.rules[0]); + expect(() => resolveRules(m, functions, 'org', 'site')).to.throw('unique'); + const a = manifest(); a.rules[0].type = 'auth'; + expect(() => resolveRules(a, functions, 'org', 'site')).to.throw('does not match'); + }); + it('never falls back to a content token or upload endpoint', () => { + sinon.stub(fs, 'readFileSync').returns(JSON.stringify({ token: 'saved-content', endpoint: 'https://upload.test/v1' })); + expect(() => rulesOptions({ c: 'org', p: 'site', t: 'content-token' }, { QUANT_TOKEN: 'env-content', QUANT_ENDPOINT: 'https://upload.test/v1' })).to.throw('QUANT_API_TOKEN'); + expect(() => rulesOptions({ c: 'org', p: 'site', 'api-token': 'scoped', 'api-base-url': 'https://upload.test/v1' }, {})).to.throw('portal'); + }); + it('posts only the scoped bearer token to the explicit portal and disables redirects', async () => { + sinon.stub(fs, 'readFileSync').callsFake(path => path === 'rules.json' ? JSON.stringify(manifest()) : path === 'functions.json' ? JSON.stringify(functions) : '{}'); + const post = sinon.stub(axios, 'post').resolves({ data: { namespace: 'orbit', rules: [{ id: 'api', status: 'created' }] } }); + sinon.stub(console, 'log'); + await rules.handler({ file: 'rules.json', functions: 'functions.json', c: 'org', p: 'site', 'api-token': 'scoped-secret', 'api-base-url': 'https://portal.test/api/v2', 'dry-run': true, token: 'content-secret' }); + expect(post.firstCall.args[0]).to.equal('https://portal.test/api/v2/organizations/org/projects/site/rules/deploy'); + expect(post.firstCall.args[2].headers).to.deep.equal({ Authorization: 'Bearer scoped-secret', Accept: 'application/json' }); + expect(post.firstCall.args[2].params).to.deep.equal({ dry_run: 1 }); + expect(post.firstCall.args[2].maxRedirects).to.equal(0); + }); + it('does not print tokens from upstream errors', async () => { + sinon.stub(fs, 'readFileSync').callsFake(path => path === 'rules.json' ? JSON.stringify(manifest()) : path === 'functions.json' ? JSON.stringify(functions) : '{}'); + sinon.stub(axios, 'post').rejects({ response: { status: 403, data: 'scoped-secret' }, message: 'scoped-secret' }); + try { await rules.handler({ file: 'rules.json', functions: 'functions.json', c: 'org', p: 'site', 'api-token': 'scoped-secret', 'api-base-url': 'https://portal.test/api/v2' }); expect.fail('must fail'); } + catch (error) { expect(error.message).to.include('403').and.not.to.include('scoped-secret'); } + }); + const run = (extra = {}) => rules.handler({ file: 'rules.json', functions: 'functions.json', c: 'org', p: 'site', 'api-token': 'scoped-secret', 'api-base-url': 'https://portal.test/api/v2', ...extra }); + const files = () => sinon.stub(fs, 'readFileSync').callsFake(path => path === 'rules.json' ? JSON.stringify(manifest()) : path === 'functions.json' ? JSON.stringify(functions) : '{}'); + it('sends force only when asked and accepts a forced overwrite', async () => { + files(); + const post = sinon.stub(axios, 'post').resolves({ data: { namespace: 'orbit', rules: [{ id: 'api', status: 'forced' }] } }); + const log = sinon.stub(console, 'log'); + expect(await run({ force: true })).to.equal('Rules deployed successfully'); + expect(post.firstCall.args[2].params).to.deep.equal({ force: 1 }); + expect(log.calledWith('api: forced')).to.equal(true); + await run(); + expect(post.secondCall.args[2].params).to.deep.equal({}); + }); + it('explains drift without printing values that could hold credentials', async () => { + files(); + const diff = { url: { live: ['/changed/*'], code: ['/api/*'] }, action_config: { live: { password: 'proxy-secret' }, code: { fn_uuid: 'x' } } }; + sinon.stub(axios, 'post').rejects({ response: { status: 409, data: { message: 'Some managed rules changed outside code.', rules: [{ id: 'api', status: 'drift', diff }] } } }); + const log = sinon.stub(console, 'log'); + try { await run(); expect.fail('must fail'); } catch (error) { + expect(error.message).to.include('--force').and.not.to.include('check portal URL'); + const printed = log.args.flat().join('\n'); + expect(printed).to.include('api: drift').and.include('url').and.include('/changed/*').and.include('action_config'); + expect(printed + error.message).not.to.include('proxy-secret'); + } + }); + it('reports a collision as never overwritable, and a busy lock as retryable', async () => { + files(); + const post = sinon.stub(axios, 'post'); + post.onFirstCall().rejects({ response: { status: 409, data: { message: 'x', rules: [{ id: 'api', status: 'collision' }] } } }); + post.onSecondCall().rejects({ response: { status: 409, data: { error: true, message: 'Rules are being modified. Retry shortly.' } } }); + sinon.stub(console, 'log'); + try { await run({ force: true }); expect.fail('must fail'); } catch (error) { expect(error.message).to.include('does not own'); } + try { await run(); expect.fail('must fail'); } catch (error) { expect(error.message).to.include('Retry'); } + }); +});