diff --git a/.ai-class b/.ai-class new file mode 100644 index 0000000..a5b73ed --- /dev/null +++ b/.ai-class @@ -0,0 +1 @@ +green diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..13f248b --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,100 @@ +name: Main CI + +on: [push] + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + node-version: [24.x] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Run eslint + run: npm run lint + test-node: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + node-version: [22.x, 24.x, 26.x] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Install with Node.js 24.x + uses: actions/setup-node@v7 + with: + node-version: 24.x + - run: npm install + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - name: Run tests with Node.js ${{ matrix.node-version }} + run: npm run test-node + test-browser: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + node-version: [24.x] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Run browser tests + run: npm run test-browser + coverage: + runs-on: ubuntu-latest + # coverage runs the browser project too, so a browser is required + timeout-minutes: 15 + strategy: + matrix: + node-version: [24.x] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Generate coverage report + run: npm run coverage-ci + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + files: ./coverage/lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b562be --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules +coverage +.nyc_output +reports +.cache +scratchpad/ +*.log +*.tgz +.eslintcache +.vscode +.project +.settings +TAGS +*~ +*.sw[nop] +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..234aa3b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# @digitalbazaar/json-pointer-primitives ChangeLog + +## 1.0.0 - TBD + +### Added +- Initial release. +- `matches({object, map})` — tests any object against a map of JSON pointers to + expected values. Every entry must match; a `Set` as a value means any of its + members may match; an empty `Map`, `Set` or string is a wildcard. An array is + the ordered `@context` case, where each element must equal the element at the + same index and the object may carry more. +- Prefer an empty `Map` as the wildcard — `{}` in a JSON-LD example, matching + JSON-LD Framing. The empty string means the same for QueryByExample + compatibility, but conflates "any value" with "the empty string". +- `toJsonPointerMap({obj, flat})` and `fromJsonPointerMap({map})` — build a + pointer map from an object and rebuild an object from one. Non-flat output + expresses arrays as a `Set`, except at `@context`, which stays ordered. +- `resolvePointer(obj, pointer)` — reads one pointer against own properties. +- `isObject`, `isNumber`, `toIntegerIfInteger`, `assert`. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5f762b6 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,26 @@ +Copyright (c) 2026 Digital Bazaar, Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2675d63 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# @digitalbazaar/json-pointer-primitives + +Building blocks for matching objects against JSON pointers: convert a nested +object into a map of pointers to values, resolve a pointer, and test whether an +object satisfies such a map. + +It does not know what it is matching. Give it any object and any map. + +```js +import {matches} from '@digitalbazaar/json-pointer-primitives'; + +matches({ + object: credential, + map: new Map([ + ['/type', 'MovieTicketCredential'], + ['/issuer/id', 'did:example:issuer'] + ]) +}); +// true — every entry matched +``` + +One runtime dependency, `json-pointer`. Runs on node and in a browser. + +## Semantics + +Every entry in the map must match, so entries are a conjunction: + +```js +const credential = { + type: ['VerifiableCredential', 'MovieTicketCredential'], + issuer: {id: 'did:example:issuer'}, + credentialSubject: {seat: 'A1', row: 12} +}; + +matches({object: credential, map: new Map([ + ['/issuer/id', 'did:example:issuer'], + ['/credentialSubject/seat', 'A1'] +])}); // true + +matches({object: credential, map: new Map([ + ['/issuer/id', 'did:example:issuer'], + ['/credentialSubject/seat', 'B2'] +])}); // false — one entry failed +``` + +A `Set` value is an alternation, nested inside that conjunction — any member +may match: + +```js +new Map([['/credentialSubject/seat', new Set(['A1', 'B2'])]]); // either seat +``` + +A pointer that resolves to an array matches if any element does: + +```js +new Map([['/type', 'MovieTicketCredential']]); // true — one of two types +``` + +An empty `Map` is a wildcard: any value, so long as the pointer resolves. An +empty `Set` and `''` mean the same; prefer the empty `Map`, which is what `{}` +in an example becomes. + +```js +new Map([['/issuer/id', new Map()]]); // true — an issuer id exists +new Map([['/absent', new Map()]]); // false — nothing there +``` + +A numeric string and a number compare equal by default: + +```js +new Map([['/credentialSubject/row', '12']]); // true +matches({object: credential, map, options: {coerceNumbers: false}}); // false +``` + +`@context` is the exception to array handling: it stays ordered, and each +element must equal the element at the same index. The object may carry more. + +A pointer that resolves to nothing never matches. Pointers read own properties +only, so `/constructor` and `/toString` resolve to nothing. + +## Building a map + +From a nested example that mirrors the object's shape: + +```js +toJsonPointerMap({obj: {credentialSubject: {name: 'John Doe'}}}); +// Map { '/credentialSubject/name' => 'John Doe' } +``` + +or by hand, from flat pointers: + +```js +new Map([ + ['/renderSuite', 'html'], + ['/template/mediaType', 'text/html'] +]); +``` + +`fromJsonPointerMap({map})` rebuilds an object from a map. + +```js +resolvePointer({issuer: {id: 'did:example:issuer'}}, '/issuer/id'); +// 'did:example:issuer' +``` + +## License + +See [LICENSE.md](./LICENSE.md). diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..10180d2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,9 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import universalConfig + from '@digitalbazaar/eslint-config/universal-recommended'; + +export default [ + ...universalConfig +]; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..cca5a74 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,8 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +export {matches} from './match.js'; +export { + assert, fromJsonPointerMap, isNumber, isObject, resolvePointer, + toIntegerIfInteger, toJsonPointerMap +} from './util.js'; diff --git a/lib/match.js b/lib/match.js new file mode 100644 index 0000000..7b483a9 --- /dev/null +++ b/lib/match.js @@ -0,0 +1,98 @@ +/*! + * Copyright (c) 2025-2026 Digital Bazaar, Inc. + */ +import {assert, isObject, resolvePointer, toIntegerIfInteger} from './util.js'; + +/** + * Returns whether an object matches against a JSON pointer map. + * + * The map is a `Map` of JSON pointer to expected value. Every entry must + * match. A `Set` as a value means any of its members may match, so alternation + * nests inside conjunction. + * + * An empty `Map`, `Set` or string is a wildcard: any value, but the pointer + * must still resolve. Prefer the empty `Map` -- `{}` in a JSON-LD example. + * The empty string means the same, for QueryByExample compatibility. + * + * Nothing here knows what it is matching. Callers build the map from whatever + * they have -- a QueryByExample `example`, a DCQL credential query, a + * Presentation Exchange input descriptor, or a hand-written set of pointers -- + * and match credentials, render methods or anything else against it. Building + * the map once and reusing it across candidates is the cheaper order. + * + * @param {object} options - The options. + * @param {object} options.object - The object to try to match. + * @param {Map} options.map - The JSON pointer map. + * @param {object} [options.options] - Match options: + * [coerceNumbers=true] - Numeric strings and numbers compare equal. + * + * @returns {boolean} `true` if the object matches, `false` if not. + */ +export function matches({object, map, options = {coerceNumbers: true}} = {}) { + // a bad `map` must not read as "matched everything" + assert(map, 'map', Map); + // only an object can match + if(!isObject(object)) { + return false; + } + return _match({cursor: object, matchValue: map, options}); +} + +function _match({cursor, matchValue, options}) { + // handle wildcard matching + if(_isWildcard(matchValue)) { + return true; + } + + if(matchValue instanceof Set) { + // some element in the set must match `cursor` + return [...matchValue].some(e => _match({cursor, matchValue: e, options})); + } + + if(matchValue instanceof Map) { + // all pointers and values in the map must match `cursor` + return [...matchValue.entries()].every(([pointer, matchValue]) => { + const value = resolvePointer(cursor, pointer); + if(value === undefined) { + // no value at `pointer`; no match + return false; + } + // handles case where `value` is an empty array + wildcard `matchValue` + if(_isWildcard(matchValue)) { + return true; + } + // normalize value to an array for matching + const values = Array.isArray(value) ? value : [value]; + // `matchValue` can only be an array for the `@context` case + if(Array.isArray(matchValue)) { + // each element of `matchValue` must be equal to the element in + // `values` at the same index (note: `values` may have more elements + // than `matchValue` and still match) + return matchValue.every((mv, i) => values[i] === mv); + } + // handle matching each individual value on its own + return values.some(v => _match({cursor: v, matchValue, options})); + }); + } + + // primitive comparison + if(cursor === matchValue) { + return true; + } + + // string/number coercion + if(options.coerceNumbers) { + const cursorNumber = toIntegerIfInteger(cursor); + const matchNumber = toIntegerIfInteger(matchValue); + return cursorNumber !== undefined && cursorNumber === matchNumber; + } + + return false; +} + +function _isWildcard(value) { + // by type, not a bare `size`, which any object can carry + return value === '' || + (value instanceof Map && value.size === 0) || + (value instanceof Set && value.size === 0); +} diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..bf5e069 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,170 @@ +/*! + * Copyright (c) 2022-2026 Digital Bazaar, Inc. + */ +import jsonpointer from 'json-pointer'; + +// refused on both the read and the write path +const UNSAFE_TOKENS = new Set(['__proto__', 'constructor', 'prototype']); + +export function assert(x, name, type, optional = false) { + const article = type === 'object' ? 'an' : 'a'; + const expected = `${article} ${type?.name ?? type}`; + if(x === undefined) { + if(optional) { + return; + } + throw new TypeError(`"${name}" is required and must be ${expected}.`); + } + const xType = typeof type === 'string' ? + typeof x : (x instanceof type && type); + if(xType !== type) { + throw new TypeError( + `${optional ? 'When present, ' : ''}"${name}" must be ${expected}.`); + } +} + +export function fromJsonPointerMap({map} = {}) { + assert(map, 'map', Map); + // the root entry returns immediately, discarding anything beside it + if(map.size > 1 && map.has('/')) { + throw new Error( + 'A root pointer "/" cannot be combined with other pointers.'); + } + return _fromPointers({map}); +} + +export function isNumber(x) { + return typeof toIntegerIfInteger(x) === 'number'; +} + +export function isObject(x) { + return x && typeof x === 'object' && !Array.isArray(x); +} + +export function resolvePointer(obj, pointer) { + if(pointer === '/') { + return obj; + } + let tokens; + try { + tokens = jsonpointer.parse(pointer); + } catch { + return undefined; + } + // walked here rather than via `jsonpointer.get`, which gates on `in` and + // so resolves inherited properties + let cursor = obj; + for(const token of tokens) { + if(UNSAFE_TOKENS.has(token) || cursor === null || + typeof cursor !== 'object' || !Object.hasOwn(cursor, token)) { + return undefined; + } + cursor = cursor[token]; + } + return cursor; +} + +// produces a map of deep pointers to primitives and sets; the values in each +// set share the same pointer value and if any value in the set is an object, +// it becomes a new map of deep pointers from that starting place; the pointer +// value for an empty objects will be an empty map +export function toJsonPointerMap({obj, flat = false} = {}) { + assert(obj, 'obj', 'object'); + if(obj === null) { + throw new TypeError('"obj" must not be null.'); + } + // `_toPointers` returns the cursor, not the map, for a top-level array + const map = new Map(); + _toPointers({cursor: obj, map, flat}); + return map; +} + +export function toIntegerIfInteger(x) { + if(typeof x === 'string') { + const i = parseInt(x, 10); + return i.toString() === x ? i : x; + } + return x; +} + +// `jsonpointer.set` assigns through `__proto__` as a final token, swapping +// the prototype of the object being rebuilt +function _assertSafePointer(pointer) { + for(const token of jsonpointer.parse(pointer)) { + if(UNSAFE_TOKENS.has(token)) { + throw new Error( + `JSON pointer "${pointer}" contains unsafe token "${token}".`); + } + } +} + +function _fromPointers({map} = {}) { + const result = {}; + + for(const [pointer, value] of map) { + // convert any non-primitive values + let val = value; + if(value instanceof Map) { + val = _fromPointers({map: value}); + } else if(value instanceof Set || Array.isArray(value)) { + // an array is the ordered `@context` container; both may hold maps + val = [...value].map(e => e instanceof Map ? _fromPointers({map: e}) : e); + } + + // if root pointer is used, `value` is result + if(pointer === '/') { + return val; + } + + _assertSafePointer(pointer); + jsonpointer.set(result, pointer, val); + } + + return result; +} + +function _toPointers({ + cursor, map, tokens = [], pointer = '/', flat = false +}) { + if(!flat && Array.isArray(cursor)) { + // when producing non-flat output, every array is treated as a `Set` except + // if the pointer points at an `@context` array (this is the only ordered + // list case) + let container; + let add; + if(pointer.endsWith('/@context')) { + container = []; + add = container.push; + } else { + container = new Set(); + add = container.add; + } + add = add.bind(container); + // result is `container` if `map` is defined, if not, then case is + // array of arrays and result is a new map + const result = map ? container : (map = new Map()); + map.set(pointer, container); + for(const element of cursor) { + // reset map, tokens, and pointer for array elements + add(_toPointers({cursor: element, flat})); + } + return result; + } + if(cursor !== null && typeof cursor === 'object') { + map = map ?? new Map(); + const entries = Object.entries(cursor); + if(entries.length === 0) { + // ensure empty object / array case is represented + map.set(pointer, Array.isArray(cursor) ? new Set() : new Map()); + } + for(const [token, value] of entries) { + tokens.push(String(token)); + pointer = jsonpointer.compile(tokens); + _toPointers({cursor: value, map, tokens, pointer, flat}); + tokens.pop(); + } + return map; + } + map?.set(pointer, cursor); + return cursor; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a3406d --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@digitalbazaar/json-pointer-primitives", + "version": "1.0.0", + "type": "module", + "description": "Match an object against a map of JSON pointers to expected values.", + "exports": "./lib/index.js", + "files": [ + "lib/*" + ], + "scripts": { + "lint": "eslint", + "test": "vitest run", + "watch": "vitest", + "test-node": "vitest run --project node", + "test-browser": "vitest run --project browser", + "coverage": "vitest run --coverage", + "coverage-ci": "vitest run --coverage --coverage.reporter=lcovonly --coverage.reporter=text-summary --coverage.reporter=text" + }, + "dependencies": { + "json-pointer": "^0.6.2" + }, + "devDependencies": { + "@digitalbazaar/eslint-config": "^9.0.0", + "@vitest/browser": "^4.1.11", + "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^10.9.1", + "playwright": "^1.62.1", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "json pointer", + "match", + "query" + ], + "author": { + "name": "Digital Bazaar, Inc.", + "email": "support@digitalbazaar.com", + "url": "https://digitalbazaar.com" + }, + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "https://github.com/digitalbazaar/json-pointer-primitives" + }, + "bugs": { + "url": "https://github.com/digitalbazaar/json-pointer-primitives/issues" + }, + "homepage": "https://github.com/digitalbazaar/json-pointer-primitives" +} diff --git a/test/context.spec.js b/test/context.spec.js new file mode 100644 index 0000000..fed776a --- /dev/null +++ b/test/context.spec.js @@ -0,0 +1,87 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {describe, expect, it} from 'vitest'; +import { + fromJsonPointerMap, matches, toJsonPointerMap +} from '../lib/index.js'; + +// `@context` is ordered: later entries override earlier ones. Every other +// array is an unordered bag of candidates. + +const V2 = 'https://www.w3.org/ns/credentials/v2'; +const EXAMPLES = 'https://www.w3.org/ns/credentials/examples/v2'; + +const CREDENTIAL = { + '@context': [V2, EXAMPLES], + type: ['VerifiableCredential', 'ExampleCredential'] +}; + +const asMap = obj => toJsonPointerMap({obj}); + +describe('building a map from an ordered array', () => { + it('keeps @context as an array', () => { + expect(asMap({'@context': [V2, EXAMPLES]}).get('/@context')) + .toEqual([V2, EXAMPLES]); + }); + + it('still makes a Set of every other array', () => { + expect(asMap({type: ['A', 'B']}).get('/type')).toEqual(new Set(['A', 'B'])); + }); + + it('keys off the pointer, not the property name alone', () => { + // a nested `@context` is ordered for the same reason the top-level one is + const map = asMap({credentialSubject: {'@context': [V2]}}); + expect(map.get('/credentialSubject/@context')).toEqual([V2]); + }); +}); + +describe('matching an ordered @context', () => { + it('matches when the order agrees', () => { + expect(matches({ + object: CREDENTIAL, map: asMap({'@context': [V2, EXAMPLES]}) + })).toBe(true); + }); + + it('does not match when the same entries are reordered', () => { + expect(matches({ + object: CREDENTIAL, map: asMap({'@context': [EXAMPLES, V2]}) + })).toBe(false); + }); + + it('matches a leading subset, since the object may carry more', () => { + expect(matches({ + object: CREDENTIAL, map: asMap({'@context': [V2]}) + })).toBe(true); + }); + + it('does not match a subset that is not the leading one', () => { + expect(matches({ + object: CREDENTIAL, map: asMap({'@context': [EXAMPLES]}) + })).toBe(false); + }); + + it('leaves other arrays unordered', () => { + expect(matches({ + object: CREDENTIAL, + map: asMap({type: ['ExampleCredential', 'VerifiableCredential']}) + })).toBe(true); + }); +}); + +describe('rebuilding an ordered @context', () => { + it('round-trips the order', () => { + const obj = {'@context': [V2, EXAMPLES]}; + expect(fromJsonPointerMap({map: asMap(obj)})).toEqual(obj); + }); + + // `_fromPointers` converted maps inside a `Set` but not inside an array, so + // an inline context object came back as a raw `Map` — which stringifies to + // `{}`, so the loss was invisible to anything that logged the result + it('rebuilds an inline context object rather than leaving a Map', () => { + const obj = {'@context': [V2, {ex: 'https://example.com/#'}]}; + const rebuilt = fromJsonPointerMap({map: asMap(obj)}); + expect(rebuilt['@context'][1]).not.toBeInstanceOf(Map); + expect(rebuilt).toEqual(obj); + }); +}); diff --git a/test/hardening.spec.js b/test/hardening.spec.js new file mode 100644 index 0000000..63a381a --- /dev/null +++ b/test/hardening.spec.js @@ -0,0 +1,95 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {describe, expect, it} from 'vitest'; +import { + fromJsonPointerMap, matches, resolvePointer, toJsonPointerMap +} from '../lib/index.js'; + +// Neither the object nor the map is trusted: both may come from another party. + +describe('a map that is not a Map', () => { + it.each([ + ['an empty string', ''], + ['an object carrying size: 0', {size: 0}], + ['a Set', new Set(['/a'])], + ['a plain object of pointers', {'/a': 1}] + ])('is refused rather than treated as a wildcard: %s', (_label, map) => { + expect(() => matches({object: {a: 1}, map})).toThrow(TypeError); + }); + + it('is refused when absent entirely', () => { + expect(() => matches({object: {a: 1}})).toThrow(/required/); + }); + + it('still matches normally once it is a real Map', () => { + expect(matches({object: {a: 1}, map: new Map([['/a', 1]])})).toBe(true); + }); +}); + +describe('pointers that reach the prototype', () => { + const DOCUMENT = {a: 1}; + + it.each(['/toString', '/valueOf', '/hasOwnProperty', '/isPrototypeOf'])( + 'does not resolve %s on a document that has no such field', pointer => { + expect(resolvePointer(DOCUMENT, pointer)).toBeUndefined(); + }); + + it.each(['/__proto__', '/constructor', '/a/constructor'])( + 'refuses the unsafe token in %s', pointer => { + expect(resolvePointer(DOCUMENT, pointer)).toBeUndefined(); + }); + + it('does not let an inherited name satisfy a wildcard', () => { + const map = new Map([['/toString', '']]); + expect(matches({object: DOCUMENT, map})).toBe(false); + }); + + it('still resolves an own property that shadows an inherited one', () => { + expect(resolvePointer({toString: 'mine'}, '/toString')).toBe('mine'); + }); +}); + +describe('rebuilding through a prototype-swapping pointer', () => { + it.each([ + ['a Map value', new Map([['/polluted', 'yes']])], + ['a primitive value', 'yes'] + ])('throws instead of swapping the prototype — %s', (_label, value) => { + const map = new Map([['/a/__proto__', value]]); + expect(() => fromJsonPointerMap({map})).toThrow(/unsafe token/); + }); + + it.each(['/constructor/x', '/a/prototype'])('throws on %s', pointer => { + expect(() => fromJsonPointerMap({map: new Map([[pointer, 1]])})) + .toThrow(/unsafe token/); + }); +}); + +describe('what toJsonPointerMap hands back', () => { + it('is a Map even for a top-level array', () => { + expect(toJsonPointerMap({obj: ['a', 'b']})).toBeInstanceOf(Map); + }); + + it.each([ + ['no argument', undefined, /required/], + ['null', {obj: null}, /must not be null/] + ])('refuses %s', (_label, args, message) => { + expect(() => toJsonPointerMap(args)).toThrow(message); + }); +}); + +describe('a root pointer beside other pointers', () => { + it.each([ + ['root last', [['/a', 1], ['/', {x: 2}]]], + ['root first', [['/', {x: 2}], ['/a', 1]]] + ])('is refused rather than silently dropping entries — %s', + (_label, entries) => { + expect(() => fromJsonPointerMap({map: new Map(entries)})) + .toThrow(/cannot be combined/); + }); + + it('is still accepted on its own', () => { + const map = new Map([['/', {x: 2}]]); + expect(fromJsonPointerMap({map})).toEqual({x: 2}); + }); +}); diff --git a/test/match.spec.js b/test/match.spec.js new file mode 100644 index 0000000..e270dc8 --- /dev/null +++ b/test/match.spec.js @@ -0,0 +1,175 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {describe, expect, it} from 'vitest'; +import {matches, toJsonPointerMap} from '../lib/index.js'; + +// Build a map the way a caller would: either from a nested example, or by +// hand from flat pointers. Both are the same `Map` in the end. +const fromExample = obj => toJsonPointerMap({obj}); +const fromPointers = obj => new Map(Object.entries(obj)); + +const CREDENTIAL = { + type: ['VerifiableCredential', 'MovieTicketCredential'], + issuer: {id: 'did:example:issuer', name: 'Utopia Cinemas'}, + credentialSubject: {name: 'John Doe', seat: 'A1', row: 12, tags: []} +}; + +const RENDER_METHOD = { + type: 'TemplateRenderMethod', + renderSuite: 'html', + name: 'front', + template: {mediaType: 'text/html'} +}; + +describe('matching an object against a pointer map', () => { + describe('conjunction — every entry must match', () => { + it('matches when all pointers match', () => { + expect(matches({ + object: CREDENTIAL, + map: fromPointers({ + '/issuer/id': 'did:example:issuer', + '/credentialSubject/seat': 'A1' + }) + })).toBe(true); + }); + + it('does not match when one pointer of several fails', () => { + expect(matches({ + object: CREDENTIAL, + map: fromPointers({ + '/issuer/id': 'did:example:issuer', + '/credentialSubject/seat': 'B2' + }) + })).toBe(false); + }); + + it('does not match a pointer that resolves to nothing', () => { + expect(matches({ + object: CREDENTIAL, map: fromPointers({'/absent': 'anything'}) + })).toBe(false); + }); + + it('matches an empty map, which asks for nothing', () => { + expect(matches({object: CREDENTIAL, map: fromExample({})})).toBe(true); + }); + }); + + describe('arrays', () => { + it('matches a value inside an array at the pointer', () => { + // `/type` resolves to an array; any member matching is a match + expect(matches({ + object: CREDENTIAL, + map: fromPointers({'/type': 'MovieTicketCredential'}) + })).toBe(true); + }); + + it('does not match a value absent from the array', () => { + expect(matches({ + object: CREDENTIAL, map: fromPointers({'/type': 'DriversLicense'}) + })).toBe(false); + }); + }); + + describe('alternation — a Set means any member may match', () => { + it('matches when one member of the set matches', () => { + const map = new Map([['/credentialSubject/seat', new Set(['B2', 'A1'])]]); + expect(matches({object: CREDENTIAL, map})).toBe(true); + }); + + it('does not match when no member matches', () => { + const map = new Map([['/credentialSubject/seat', new Set(['B2', 'C3'])]]); + expect(matches({object: CREDENTIAL, map})).toBe(false); + }); + }); + + describe('wildcards', () => { + // an empty Map is what `{}` in an example becomes -- the preferred form. + // The empty string is QueryByExample's `"firstName": ""` convention. + it.each([ + ['an empty Map', new Map()], + ['an empty Set', new Set()], + ['an empty string', ''] + ])('treats %s as "any value at this pointer"', (_label, wildcard) => { + const map = new Map([['/credentialSubject/name', wildcard]]); + expect(matches({object: CREDENTIAL, map})).toBe(true); + }); + + it('cannot tell "any value" from "the empty string"', () => { + const map = new Map([['/credentialSubject/seat', '']]); + expect(matches({object: CREDENTIAL, map})).toBe(true); + expect(matches({ + object: {credentialSubject: {seat: ''}}, map + })).toBe(true); + }); + + it('still requires the pointer to resolve', () => { + const map = new Map([['/absent', '']]); + expect(matches({object: CREDENTIAL, map})).toBe(false); + }); + + it('matches an empty array against the empty-array wildcard', () => { + expect(matches({ + object: CREDENTIAL, map: fromExample({credentialSubject: {tags: []}}) + })).toBe(true); + }); + }); + + describe('number coercion', () => { + it('compares a numeric string equal to a number by default', () => { + expect(matches({ + object: CREDENTIAL, map: fromPointers({'/credentialSubject/row': '12'}) + })).toBe(true); + }); + + it('does not coerce when told not to', () => { + expect(matches({ + object: CREDENTIAL, + map: fromPointers({'/credentialSubject/row': '12'}), + options: {coerceNumbers: false} + })).toBe(false); + }); + }); + + describe('what it refuses', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['a string', 'not an object'], + ['an array', [1, 2]] + ])('does not match %s', (_label, object) => { + expect(matches({object, map: fromExample({})})).toBe(false); + }); + }); + + // The reason this package exists apart from any credential library: the + // matcher has no idea what it is matching. + describe('objects that are not credentials', () => { + it('matches a render method by its declared fields', () => { + expect(matches({ + object: RENDER_METHOD, + map: fromPointers({ + '/type': 'TemplateRenderMethod', + '/renderSuite': 'html', + '/template/mediaType': 'text/html' + }) + })).toBe(true); + }); + + it('rejects a render method the caller cannot display', () => { + expect(matches({ + object: {...RENDER_METHOD, renderSuite: 'nfc'}, + map: fromPointers({'/renderSuite': 'html'}) + })).toBe(false); + }); + + it('accepts a map built from a nested example just the same', () => { + expect(matches({ + object: RENDER_METHOD, + map: fromExample({ + renderSuite: 'html', template: {mediaType: 'text/html'} + }) + })).toBe(true); + }); + }); +}); diff --git a/test/pointers.spec.js b/test/pointers.spec.js new file mode 100644 index 0000000..b0b2f65 --- /dev/null +++ b/test/pointers.spec.js @@ -0,0 +1,211 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import { + assert, fromJsonPointerMap, isNumber, resolvePointer, toIntegerIfInteger, + toJsonPointerMap +} from '../lib/index.js'; +import {describe, expect, it} from 'vitest'; + +// Pins the pointer behaviour inherited from `json-pointer`, which nothing +// above the dependency enforces. `resolvePointer` catches every error, so +// there only its own output contract is pinned, not the dependency's. + +describe('resolving a pointer', () => { + // RFC 6901: `-` names the element after the last one, so it must never + // resolve to a value on a read. + describe('the `-` token', () => { + it('resolves to nothing on an array', () => { + expect(resolvePointer({foo: ['a', 'b']}, '/foo/-')).toBeUndefined(); + }); + + it('resolves a literal `-` key on an object', () => { + // the special meaning belongs to arrays only + expect(resolvePointer({foo: {'-': 'dash'}}, '/foo/-')).toBe('dash'); + }); + }); + + describe('escaping', () => { + // `~1` decodes before `~0`, or `~01` wrongly yields `/` + it.each([ + ['/m~0n', {'m~n': 'tilde'}, 'tilde'], + ['/a~1b', {'a/b': 'slash'}, 'slash'], + ['/~01', {'~1': 'escaped tilde-one'}, 'escaped tilde-one'] + ])('decodes %s', (pointer, object, expected) => { + expect(resolvePointer(object, pointer)).toBe(expected); + }); + }); + + describe('array indices', () => { + it('resolves an in-range index', () => { + expect(resolvePointer({foo: ['a', 'b']}, '/foo/1')).toBe('b'); + }); + + it('resolves to nothing past the end', () => { + expect(resolvePointer({foo: ['a', 'b']}, '/foo/2')).toBeUndefined(); + }); + + it('rejects a leading zero rather than reading it as an index', () => { + // RFC 6901 array tokens are base-10 with no leading zeros + expect(resolvePointer({foo: ['a', 'b']}, '/foo/01')).toBeUndefined(); + }); + }); + + // A missing segment must abandon the whole pointer, not fall back to the + // root and keep walking. The root has to carry a value for the remaining + // token, or the two behaviours are indistinguishable. + it('resolves to nothing when a segment is missing', () => { + const doc = {b: 'a value the root would supply', a: {}}; + expect(resolvePointer(doc, '/absent/b')).toBeUndefined(); + }); + + // A deliberate departure from RFC 6901, where `""` is the whole document + // and `"/"` is the value under the empty-string key. Here both mean the + // whole document, so an empty-string key cannot be addressed. + describe('the root pointer convention', () => { + const DOC = {'': 'under the empty key', a: 1}; + + it.each([['the empty string', ''], ['a lone slash', '/']])( + 'treats %s as the whole document', (_label, pointer) => { + expect(resolvePointer(DOC, pointer)).toEqual(DOC); + }); + }); +}); + +describe('recognising integer tokens', () => { + it.each([ + ['a plain integer string', '12', 12, true], + ['a negative integer string', '-5', -5, true], + ['a number', 12, 12, true], + ['a leading zero', '01', '01', false], + ['a decimal', '1.5', '1.5', false], + ['a trailing-garbage number', '12abc', '12abc', false], + ['the dash token', '-', '-', false], + ['an empty string', '', '', false], + ['a word', 'abc', 'abc', false] + ])('%s', (_label, input, converted, numeric) => { + expect(toIntegerIfInteger(input)).toBe(converted); + expect(isNumber(input)).toBe(numeric); + }); +}); + +describe('building a pointer map from an object', () => { + const EXAMPLE = { + type: ['A', 'B'], sub: {seat: 'A1'}, tags: [], empty: {} + }; + + it('turns an array into a Set of candidate values', () => { + // the nested form is what alternation matching consumes + expect(toJsonPointerMap({obj: EXAMPLE}).get('/type')) + .toEqual(new Set(['A', 'B'])); + }); + + it('turns an array into indexed pointers when flat', () => { + const map = toJsonPointerMap({obj: EXAMPLE, flat: true}); + expect([...map]).toEqual(expect.arrayContaining([ + ['/type/0', 'A'], ['/type/1', 'B'] + ])); + }); + + it('escapes reserved characters on the way out', () => { + const map = toJsonPointerMap({obj: {'m~n': 1, 'a/b': 2}, flat: true}); + expect([...map.keys()]).toEqual(['/m~0n', '/a~1b']); + }); + + // `_isWildcard` keys off size, so the container has to arrive empty. + it.each([ + ['an empty array', 'tags', new Set()], + ['an empty object', 'empty', new Map()] + ])('represents %s as an empty container', (_label, key, expected) => { + expect(toJsonPointerMap({obj: EXAMPLE}).get(`/${key}`)).toEqual(expected); + }); +}); + +describe('rebuilding an object from a pointer map', () => { + it('round-trips a nested object', () => { + const obj = {issuer: {id: 'did:example:1'}, type: ['A', 'B']}; + const map = toJsonPointerMap({obj, flat: true}); + expect(fromJsonPointerMap({map})).toEqual(obj); + }); + + it('round-trips escaped keys', () => { + const obj = {'m~n': 1, 'a/b': 2}; + const map = toJsonPointerMap({obj, flat: true}); + expect(fromJsonPointerMap({map})).toEqual(obj); + }); + + it('expands a Set into an array', () => { + const map = new Map([['/type', new Set(['A', 'B'])]]); + expect(fromJsonPointerMap({map})).toEqual({type: ['A', 'B']}); + }); + + it('returns the value itself for the root pointer', () => { + const map = new Map([['/', {whole: 'document'}]]); + expect(fromJsonPointerMap({map})).toEqual({whole: 'document'}); + }); + + // Container type is inferred from the next token's shape. A caller that + // compiles "any array index" to the literal token `0` depends on this. + describe('inferring a container from the next token', () => { + it('builds an array from a numeric token', () => { + const map = new Map([['/a/0', 'x'], ['/a/1', 'y']]); + expect(fromJsonPointerMap({map})).toEqual({a: ['x', 'y']}); + }); + + it('builds an array from the `-` token, appending', () => { + expect(fromJsonPointerMap({map: new Map([['/a/-', 'x']])})) + .toEqual({a: ['x']}); + }); + + it('builds an object from a non-numeric token', () => { + expect(fromJsonPointerMap({map: new Map([['/a/b', 'x']])})) + .toEqual({a: {b: 'x'}}); + }); + + // Consequence: an object keyed by numeric strings does not survive. + it('cannot rebuild an object whose keys are numeric strings', () => { + const map = toJsonPointerMap({obj: {a: {0: 'x'}}, flat: true}); + expect(fromJsonPointerMap({map})).toEqual({a: ['x']}); + }); + }); +}); + +describe('asserting a value type', () => { + it('accepts a value of the named primitive type', () => { + expect(() => assert({}, 'name', 'object')).not.toThrow(); + }); + + it('accepts an instance of the named class', () => { + expect(() => assert(new Map(), 'name', Map)).not.toThrow(); + }); + + it.each([ + ['a primitive mismatch', 'a string', 'object', /must be an object/], + ['a class mismatch', {}, Map, /must be a Map/] + ])('rejects %s', (_label, value, type, message) => { + expect(() => assert(value, 'name', type)).toThrow(TypeError); + expect(() => assert(value, 'name', type)).toThrow(message); + }); + + it('names the offending parameter', () => { + expect(() => assert('a string', 'credentialQuery', 'object')) + .toThrow(/credentialQuery/); + }); + + describe('a missing value', () => { + it('is refused when the parameter is not optional', () => { + expect(() => assert(undefined, 'name', Map)).toThrow(/required/); + }); + + it('is accepted when the parameter is optional', () => { + expect(() => assert(undefined, 'name', Map, true)).not.toThrow(); + }); + }); + + it('prefixes the message only when marked optional', () => { + expect(() => assert('a string', 'name', 'object', true)) + .toThrow(/When present,/); + expect(() => assert('a string', 'name', 'object', false)) + .not.toThrow(/When present,/); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..23ddbd6 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,40 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {defineConfig} from 'vitest/config'; +import {playwright} from '@vitest/browser-playwright'; + +// The same spec files run in both places. Nothing here is node-specific, so a +// spec that passes in one and fails in the other is a real portability bug. +export default defineConfig({ + test: { + // `coverage` is process-wide: it can only be set at the root, never inside + // a project, and applies across every project in the run + coverage: { + provider: 'v8', + reporter: ['lcov', 'text-summary', 'text'], + include: ['lib/**/*.js'] + }, + projects: [ + { + test: { + name: 'node', + environment: 'node', + include: ['test/*.spec.js'] + } + }, + { + test: { + name: 'browser', + include: ['test/*.spec.js'], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{browser: 'chromium'}] + } + } + } + ] + } +});