diff --git a/package-lock.json b/package-lock.json index 23a3446..a6cbb4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ }, "devDependencies": { "c8": "12.0.0", - "esbuild": "^0.28.2" + "esbuild": "^0.28.2", + "fast-check": "4.10.2" }, "engines": { "node": ">=22" @@ -723,6 +724,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", @@ -1007,6 +1031,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 4a5792e..f5ebe6c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ ], "devDependencies": { "c8": "12.0.0", - "esbuild": "^0.28.2" + "esbuild": "^0.28.2", + "fast-check": "4.10.2" }, "bugs": { "url": "https://github.com/RexCode-Digital/shopify-scope-guard/issues" diff --git a/test/property-fuzz.test.js b/test/property-fuzz.test.js new file mode 100644 index 0000000..d990407 --- /dev/null +++ b/test/property-fuzz.test.js @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseConfig } from '../src/config/index.js'; +import { parseGraphQL } from '../src/graphql/index.js'; + +const characters = [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_']; +const atom = fc.array(fc.constantFrom(...characters), { minLength: 1, maxLength: 16 }) + .map((value) => value.join('')); +const options = { seed: 20261025, numRuns: 100, endOnFailure: true }; + +test('bounded fragment chains produce stable field paths without false positives', () => { + fc.assert(fc.property( + fc.integer({ min: 1, max: 12 }), + fc.array(atom.map((value) => `field_${value}`), { minLength: 1, maxLength: 10 }), + (depth, fields) => { + const fragments = Array.from({ length: depth }, (_, index) => { + const body = index + 1 < depth ? `...${'Fragment' + (index + 1)}` : fields.join(' '); + return `fragment Fragment${index} on Shop { ${body} }`; + }); + const source = `query Generated { shop { ...Fragment0 } }\n${fragments.join('\n')}`; + const first = parseGraphQL(source, 'generated.graphql', 4); + const second = parseGraphQL(source, 'generated.graphql', 4); + assert.deepEqual(first, second); + assert.deepEqual(first[0].fields.map((field) => field.path), [ + 'shop', + ...fields.map((field) => `shop.${field}`), + ]); + assert.ok(first[0].fields.every((field) => field.line >= 5)); + }, + ), options); +}); + +test('generated unresolved and cyclic fragments fail closed', () => { + fc.assert(fc.property(atom, (name) => { + const unresolved = `query Generated { shop { ...Missing_${name} } }`; + const cyclic = `query Generated { shop { ...Fragment_${name} } } fragment Fragment_${name} on Shop { ...Fragment_${name} }`; + assert.throws(() => parseGraphQL(unresolved, 'generated.graphql'), /Unresolved or cyclic GraphQL fragment/); + assert.throws(() => parseGraphQL(cyclic, 'generated.graphql'), /Unresolved or cyclic GraphQL fragment/); + }), options); +}); + +test('malformed Shopify TOML errors remain bounded and do not echo generated values', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-toml-property-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const file = path.join(root, 'shopify.app.toml'); + fc.assert(fc.property(atom, (value) => { + const sentinel = `SYNTHETIC_SECRET_${value}`; + fs.writeFileSync(file, `[access_scopes\nscopes = "read_${value}"\n# ${sentinel}\n`); + assert.throws(() => parseConfig(file), (error) => { + assert.equal(error.message, 'Invalid Shopify app TOML; contents were not printed'); + assert.equal(error.message.includes(sentinel), false); + return true; + }); + }), options); +});