From 0c3fd6fa130e31922d7ab24bb29f4d786e26fee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Fri, 23 Oct 2026 09:19:15 +0300 Subject: [PATCH] test: add bounded security properties --- package-lock.json | 41 ++++++++++++++++++++++++ package.json | 1 + tests/property-fuzz.test.mjs | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 tests/property-fuzz.test.mjs diff --git a/package-lock.json b/package-lock.json index 3bbb429..1703ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/node": "26.6.3", "@vercel/ncc": "0.45.0", "c8": "12.0.0", + "fast-check": "4.10.2", "typescript": "7.0.2" }, "engines": { @@ -640,6 +641,29 @@ "node": ">=6" } }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -915,6 +939,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index 8409190..7856cc1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/node": "26.6.3", "@vercel/ncc": "0.45.0", "c8": "12.0.0", + "fast-check": "4.10.2", "typescript": "7.0.2" }, "license": "MIT", diff --git a/tests/property-fuzz.test.mjs b/tests/property-fuzz.test.mjs new file mode 100644 index 0000000..176cd9f --- /dev/null +++ b/tests/property-fuzz.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import { compareConfigs } from '../dist/core.js'; + +const characters = [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-']; +const atom = fc.array(fc.constantFrom(...characters), { minLength: 1, maxLength: 20 }) + .map((value) => value.join('')); +const config = (extra = {}) => ({ access_scopes: { scopes: 'read_products' }, ...extra }); +const propertyOptions = { seed: 20261023, numRuns: 100, endOnFailure: true }; + +test('webhook grouping and ordering do not create findings or expose delivery values', () => { + fc.assert(fc.property( + fc.array(atom, { minLength: 1, maxLength: 8 }), + fc.array(atom, { maxLength: 8 }), + atom, + (topics, fields, destination) => { + const before = config({ webhooks: { subscriptions: [{ + topics, + uri: `https://hooks.example.test/${destination}`, + include_fields: fields, + }] } }); + const after = config({ webhooks: { subscriptions: [...topics].reverse().map((topic) => ({ + topics: [topic, topic], + uri: `https://hooks.example.test/${destination}`, + include_fields: [...fields].reverse(), + })) } }); + const findings = compareConfigs(before, after); + assert.deepEqual(findings, []); + assert.deepEqual(findings, compareConfigs(before, after)); + assert.equal(JSON.stringify(findings).includes(destination), false); + }, + ), propertyOptions); +}); + +test('webhook delivery changes stay redacted for bounded adversarial values', () => { + fc.assert(fc.property(atom, (value) => { + const oldDestination = `https://hooks.example.test/old-${value}?token=old-${value}`; + const newDestination = `https://hooks.example.test/new-${value}?token=new-${value}`; + const before = config({ webhooks: { subscriptions: [{ topics: ['orders/create'], uri: oldDestination }] } }); + const after = config({ webhooks: { subscriptions: [{ topics: ['orders/create'], uri: newDestination }] } }); + const findings = compareConfigs(before, after); + const output = JSON.stringify(findings); + assert.equal(findings.filter((finding) => finding.ruleId === 'WEBHOOK_SUBSCRIPTIONS_CHANGED').length, 1); + assert.match(findings[0].summary, /modified: 1/); + assert.equal(output.includes(oldDestination), false); + assert.equal(output.includes(newDestination), false); + }), propertyOptions); +}); + +test('GraphQL fragments with formatting-only changes remain equivalent', () => { + const fieldName = atom.map((value) => `field_${value}`); + fc.assert(fc.property(fc.array(fieldName, { minLength: 1, maxLength: 12 }), (fields) => { + const compact = `query Events { product { ...SelectedFields } } fragment SelectedFields on Product { ${fields.join(' ')} }`; + const formatted = `# formatting comment\nquery Events { product { ...SelectedFields } }\nfragment SelectedFields on Product {\n ${fields.join(',\n ')}\n}`; + const eventConfig = (query) => config({ events: { + api_version: '2026-10', + subscription: [{ handle: 'generated', topic: 'Product', actions: ['create'], uri: '/events', query }], + } }); + assert.deepEqual(compareConfigs(eventConfig(compact), eventConfig(formatted)), []); + }), propertyOptions); +});