From a4b5cae767abe68b5d8f6a16c23765781220262f Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Fri, 5 Sep 2025 09:23:51 -0700 Subject: [PATCH 1/4] Implement full Query By Example matching. - Creates new queryByExample module with comprehensive field matching logic - Converts VPR examples to JSON pointers for precise credential filtering - Updates presentations.js filter chain to use new matching capabilities - Adds comprehensive unit tests covering all matching scenarios - Replaces basic context-only filtering with full example-based matching Previous implementation only matched @context and specific Open Badge fields. New implementation matches ALL fields specified in Query By Example requests using JSON pointer traversal and flexible value comparison logic. --- lib/index.js | 3 +- lib/presentations.js | 29 ++++ lib/queryByExample.js | 243 ++++++++++++++++++++++++++++++++ test/web/15-query-by-example.js | 121 ++++++++++++++++ 4 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 lib/queryByExample.js create mode 100644 test/web/15-query-by-example.js diff --git a/lib/index.js b/lib/index.js index eb97e37..01ff5a4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,12 +20,13 @@ import * as exchanges from './exchanges/index.js'; import * as helpers from './helpers.js'; import * as inbox from './inbox.js'; import * as presentations from './presentations.js'; +import * as queryByExample from './queryByExample.js'; import * as users from './users.js'; import * as validator from './validator.js'; import * as zcap from './zcap.js'; export { ageCredentialHelpers, capabilities, cryptoSuites, exchanges, - helpers, inbox, presentations, users, validator, zcap + helpers, inbox, presentations, queryByExample, users, validator, zcap }; export { getCredentialStore, getProfileEdvClient, initialize, profileManager diff --git a/lib/presentations.js b/lib/presentations.js index 4dcd7b0..9192ee4 100644 --- a/lib/presentations.js +++ b/lib/presentations.js @@ -12,6 +12,7 @@ import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import {documentLoader} from './documentLoader.js'; import {ensureLocalCredentials} from './ageCredentialHelpers.js'; import jsonpointer from 'json-pointer'; +import {matchCredentials} from './queryByExample.js'; import {profileManager} from './state.js'; import {supportedSuites} from './cryptoSuites.js'; import {v4 as uuid} from 'uuid'; @@ -390,6 +391,7 @@ async function _getMatches({ // FIXME: add more generalized matching result.matches = matches .filter(_matchContextFilter({credentialQuery})) + .filter(_matchQueryByExampleFilter({credentialQuery})) .filter(_openBadgeFilter({credentialQuery})); // create derived VCs for each match based on specific `credentialQuery` @@ -418,6 +420,33 @@ function _handleLegacyDraftCryptosuites({presentation}) { } } +/** + * Creates a filter function that matches credentials against a Query By Example + * specification using the reusable queryByExample module. This function acts + * as an adapter between the bedrock wallet's filter chain pattern and the + * reusable matchCredentials function. + * + * @param {object} options - The options to use. + * @param {object} options.credentialQuery - The credential query containing + * the example to match against. + * + * @returns {Function} A filter function that returns true if the credential + * matches the Query By Example specification. + */ +function _matchQueryByExampleFilter({credentialQuery}) { + const {example} = credentialQuery; + if(!(example && typeof example === 'object')) { + // no example to match against, allow all credentials + return () => true; + } + + return ({record: {content}}) => { + // Use reusable module to check if this single credential matches + const matches = matchCredentials([content], credentialQuery); + return matches.length > 0; + }; +} + function _openBadgeFilter({credentialQuery}) { return ({record: {content}}) => { const {example} = credentialQuery; diff --git a/lib/queryByExample.js b/lib/queryByExample.js new file mode 100644 index 0000000..4d561ed --- /dev/null +++ b/lib/queryByExample.js @@ -0,0 +1,243 @@ +/*! + * Copyright (c) 2018-2025 Digital Bazaar, Inc. All rights reserved. + */ +import JsonPointer from 'json-pointer'; + +/** + * Matches credentials against a Query By Example specification. + * This function processes the full QueryByExample matching on a list of VCs + * that have already been preliminarily filtered (e.g., by top-level type). + * + * @param {Array} credentials - Array of credential objects to match against. + * @param {object} queryByExample - The Query By Example specification. + * @param {object} queryByExample.example - The example credential structure + * to match against. + * + * @returns {Array} Array of credentials that match the Query By Example + * specification. + */ +export function matchCredentials(credentials, queryByExample) { + const {example} = queryByExample; + if(!(example && typeof example === 'object')) { + // no example to match against, return all credentials + return credentials; + } + + // Convert example to JSON pointers, excluding @context as it's handled + // separately + const expectedPointers = _convertExampleToPointers(example); + + // DEBUG - remove after testing + // console.log('Query By Example Debug:', { + // example, + // expectedPointers, + // credentialCount: credentials.length + // }); + + if(expectedPointers.length === 0) { + // no meaningful fields to match, return all credentials + return credentials; + } + + return credentials.filter(credential => { + // Check each pointer against the credential content + return expectedPointers.every(({pointer, expectedValue}) => { + try { + const actualValue = JsonPointer.get(credential, pointer); + const result = _valuesMatch(actualValue, expectedValue); + + // DEBUG - remove after testing + // console.log('Matching:', + // {pointer, expectedValue, actualValue, result}); + + return result; + } catch(e) { + // If pointer doesn't exist in credential, it's not a match + console.log('Pointer error:', pointer, e.message); + return false; + } + }); + }); +} + +/** + * Converts an example to an array of JSON pointer/value pairs. + * This function recursively processes the example object to extract all + * field paths and their expected values, excluding @context which is + * handled separately in the filtering pipeline. + * + * @param {object} example - The example object from Query By Example. + * + * @returns {Array} Array of objects with {pointer, expectedValue} + * where pointer is a JSON pointer string (e.g., '/credentialSubject/name') + * and expectedValue is the expected value at the path. + */ +function _convertExampleToPointers(example) { + const pointers = []; + + // Create a copy without @context since it's handled by _matchContextFilter + const exampleWithoutContext = {...example}; + delete exampleWithoutContext['@context']; + + // Convert to JSON pointer dictionary and extract pointer/value pairs + try { + const dict = JsonPointer.dict(exampleWithoutContext); + for(const [pointer, value] of Object.entries(dict)) { + // Skip empty objects, arrays, or null/undefined values + if(_isMatchableValue(value)) { + pointers.push({ + pointer, + expectedValue: value + }); + } + } + } catch(e) { + // If JSON pointer conversion fails, return empty array + console.warn('Failed to convert example to JSON pointers:', e); + return []; + } + return pointers; +} + +/** + * Determines if a value is suitable for matching. We skip empty objects, + * empty arrays, null, undefined, and other non-meaningful values. + * + * @param {*} value - The value to check. + * + * @returns {boolean} True if the value should be used for matching. + */ +function _isMatchableValue(value) { + // Skip null, undefined + if(value == null) { + return false; + } + + // Skip empty arrays + if(Array.isArray(value) && value.length === 0) { + return false; + } + + // Skip empty objects + if(typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === 0) { + return false; + } + + // All other values (strings, numbers, booleans, non-empty arrays/objects) + return true; +} + +/** + * Determines if an actual value from a credential matches an expected value + * from a Query By Example specification. This handles various matching + * scenarios including arrays, different types, and normalization. + * + * @param {*} actualValue - The value found in the credential. + * @param {*} expectedValue - The expected value from the example. + * + * @returns {boolean} True if the values match according to Query By Example + * matching rules. + */ +function _valuesMatch(actualValue, expectedValue) { + // Handle null/undefined cases + if(actualValue == null && expectedValue == null) { + return true; + } + if(actualValue == null || expectedValue == null) { + return false; + } + + // If both are arrays, check if they have common elements + if(Array.isArray(actualValue) && Array.isArray(expectedValue)) { + return _arraysHaveCommonElements(actualValue, expectedValue); + } + + // If actual is array but expected is single value, check if array + // contains the value + if(Array.isArray(actualValue) && !Array.isArray(expectedValue)) { + return actualValue.some(item => _valuesMatch(item, expectedValue)); + } + + // If expected is array but actual is single value, check if actual + // is in expected + if(!Array.isArray(actualValue) && Array.isArray(expectedValue)) { + return expectedValue.some(item => _valuesMatch(actualValue, item)); + } + + // For objects, do deep equality comparison + if(typeof actualValue === 'object' && typeof expectedValue === 'object') { + return _objectsMatch(actualValue, expectedValue); + } + + // For primitive values, do strict equality with string normalization + return _primitiveValuesMatch(actualValue, expectedValue); +} + +/** + * Checks if two arrays have any common elements. + * + * @param {Array} arr1 - First array. + * @param {Array} arr2 - Second array. + * + * @returns {boolean} True if arrays have at least one common element. + */ +function _arraysHaveCommonElements(arr1, arr2) { + return arr1.some(item1 => + arr2.some(item2 => _valuesMatch(item1, item2)) + ); +} + +/** + * Performs deep equality comparison for objects. + * + * @param {object} obj1 - First object. + * @param {object} obj2 - Second object. + * + * @returns {boolean} True if objects are deeply equal. + */ +function _objectsMatch(obj1, obj2) { + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + // Check if they have the same number of keys + if(keys1.length !== keys2.length) { + return false; + } + + // Check if all keys and values match + return keys1.every(key => + keys2.includes(key) && _valuesMatch(obj1[key], obj2[key]) + ); +} + +/** + * Compares primitive values (string, numbers, booleans) with appropriate + * normalization and type coercion. + * + * @param {*} actual - Actual primitive value. + * @param {*} expected - Expected primitive value. + * + * @returns {boolean} True if primitive value match. + */ +function _primitiveValuesMatch(actual, expected) { + // Strict equality first (handles numbers, booleans, exact strings) + if(actual === expected) { + return true; + } + + // String comparison with normalization + if(typeof actual === 'string' && typeof expected === 'string') { + // Trim whitespace and compare case-sensitively + return actual.trim() === expected.trim(); + } + + // Type coercion for string/number comparisons + if((typeof actual === 'string' && typeof expected === 'number') || + (typeof actual === 'number' && typeof expected === 'string')) { + return String(actual) === String(expected); + } + + // No match + return false; +} diff --git a/test/web/15-query-by-example.js b/test/web/15-query-by-example.js new file mode 100644 index 0000000..ffc4efa --- /dev/null +++ b/test/web/15-query-by-example.js @@ -0,0 +1,121 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import {queryByExample} from '@bedrock/web-wallet'; +const {matchCredentials} = queryByExample; + +describe('queryByExample', function() { + describe('matchCredentials()', function() { + const mockCredentials = [ + { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'John Doe', + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Science' + }, + alumniOf: { + name: 'University of Example' + } + } + }, + { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'DriverLicense'], + credentialSubject: { + id: 'did:example:456', + name: 'Jane Smith', + licenseClass: 'A' + } + } + ]; + + it('should match credentials by single field', async function() { + const query = { + example: { + credentialSubject: { + name: 'John Doe' + } + } + }; + + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + }); + + it('should match credentials by nested object fields', async function() { + const query = { + example: { + credentialSubject: { + degree: { + type: 'BachelorDegree' + } + } + } + }; + + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(1); + matches[0].credentialSubject.degree.type.should.equal('BachelorDegree'); + }); + + it('should match credentials by array type field', async function() { + const query = { + example: { + type: 'UniversityDegreeCredential' + } + }; + + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(1); + matches[0].type.should.include('UniversityDegreeCredential'); + }); + + it('should return empty array when no matches found', async function() { + const query = { + example: { + credentialSubject: { + degree: { + type: 'MastersDegree' // doesn't exist in test data + } + } + } + }; + + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(0); + }); + + it('should return all credentials when no example provided', + async function() { + const query = {}; + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(2); + }); + + it('should match multiple fields (AND logic)', async function() { + const query = { + example: { + type: 'UniversityDegreeCredential', + credentialSubject: { + degree: { + name: 'Bachelor of Science' + } + } + } + }; + + const matches = matchCredentials(mockCredentials, query); + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + // Also verify the fields queried for are present + matches[0].type.should.include('UniversityDegreeCredential'); + matches[0].credentialSubject.degree.name + .should.equal('Bachelor of Science'); + }); + }); +}); From 07e243aa2c92ea76ca43ce5afac2422e790409d4 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Fri, 5 Sep 2025 11:35:33 -0700 Subject: [PATCH 2/4] Remove DEBUG logs --- lib/queryByExample.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/queryByExample.js b/lib/queryByExample.js index 4d561ed..ab3c567 100644 --- a/lib/queryByExample.js +++ b/lib/queryByExample.js @@ -27,13 +27,6 @@ export function matchCredentials(credentials, queryByExample) { // separately const expectedPointers = _convertExampleToPointers(example); - // DEBUG - remove after testing - // console.log('Query By Example Debug:', { - // example, - // expectedPointers, - // credentialCount: credentials.length - // }); - if(expectedPointers.length === 0) { // no meaningful fields to match, return all credentials return credentials; @@ -46,10 +39,6 @@ export function matchCredentials(credentials, queryByExample) { const actualValue = JsonPointer.get(credential, pointer); const result = _valuesMatch(actualValue, expectedValue); - // DEBUG - remove after testing - // console.log('Matching:', - // {pointer, expectedValue, actualValue, result}); - return result; } catch(e) { // If pointer doesn't exist in credential, it's not a match From 4d42d8101d3da66afe4dc32794c750f44cca96b9 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Fri, 5 Sep 2025 15:05:15 -0700 Subject: [PATCH 3/4] Optimize Query By Example for batch processing. Replaces individual credential filtering with efficient batch processing to address code review feedback on performance. Processes all credentials at once instead of calling matchCredentials() separately for each item. - Updates presentations.js to use batch processing approach - Removes inefficient _matchQueryByExampleFilter function - Extracts all credential contents, processes in single call - Maps results back using reference comparison for O(1) lookup - Adds integration test to verify batch processing functionality - Removes unused profile setup from integration test --- lib/presentations.js | 48 +++++++------------ test/web/10-api.js | 84 +++++++++++++++++++++++++++++++++ test/web/15-query-by-example.js | 36 ++++++++++++++ 3 files changed, 137 insertions(+), 31 deletions(-) diff --git a/lib/presentations.js b/lib/presentations.js index 9192ee4..83219f2 100644 --- a/lib/presentations.js +++ b/lib/presentations.js @@ -390,9 +390,23 @@ async function _getMatches({ // match any open badge achievement ID // FIXME: add more generalized matching result.matches = matches - .filter(_matchContextFilter({credentialQuery})) - .filter(_matchQueryByExampleFilter({credentialQuery})) - .filter(_openBadgeFilter({credentialQuery})); + .filter(_matchContextFilter({credentialQuery})); + + // Full query by example matching implemented via queryByExample module + // Process all credentials at once for efficiency + if(credentialQuery?.example) { + + const allContents = result.matches.map(match => match.record.content); + const matchingContents = matchCredentials(allContents, credentialQuery); + + // Map results back to original records using reference comparison + result.matches = result.matches.filter(match => + matchingContents.includes(match.record.content) + ); + } + + result.matches = + result.matches.filter(_openBadgeFilter({credentialQuery})); // create derived VCs for each match based on specific `credentialQuery` const updatedQuery = {...vprQuery, credentialQuery}; @@ -401,7 +415,6 @@ async function _getMatches({ vprQuery: updatedQuery, matches: result.matches }); } - return results; } @@ -420,33 +433,6 @@ function _handleLegacyDraftCryptosuites({presentation}) { } } -/** - * Creates a filter function that matches credentials against a Query By Example - * specification using the reusable queryByExample module. This function acts - * as an adapter between the bedrock wallet's filter chain pattern and the - * reusable matchCredentials function. - * - * @param {object} options - The options to use. - * @param {object} options.credentialQuery - The credential query containing - * the example to match against. - * - * @returns {Function} A filter function that returns true if the credential - * matches the Query By Example specification. - */ -function _matchQueryByExampleFilter({credentialQuery}) { - const {example} = credentialQuery; - if(!(example && typeof example === 'object')) { - // no example to match against, allow all credentials - return () => true; - } - - return ({record: {content}}) => { - // Use reusable module to check if this single credential matches - const matches = matchCredentials([content], credentialQuery); - return matches.length > 0; - }; -} - function _openBadgeFilter({credentialQuery}) { return ({record: {content}}) => { const {example} = credentialQuery; diff --git a/test/web/10-api.js b/test/web/10-api.js index d819d65..14866c3 100644 --- a/test/web/10-api.js +++ b/test/web/10-api.js @@ -299,3 +299,87 @@ describe('presentations.sign()', function() { ); }); }); + +describe('presentations.match()', function() { + it('should match credentials using Query By Example with batch processing', + async () => { + + // Create a mock credential store with test credentials + const mockCredentialStore = { + local: { + // Empty local store for simplicity + find: async () => ({documents: []}) + }, + remote: { + find: async () => { + // Return mock credentials that match our test + const mockCredentials = [ + { + content: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + credentialSubject: { + id: 'did:example:test1', + name: 'Alice', + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Computer Science' + } + } + }, + meta: {id: 'credential-1'} + }, + { + content: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'DriverLicense'], + credentialSubject: { + id: 'did:example:test2', + name: 'Bob' + } + }, + meta: {id: 'credential-2'} + } + ]; + + // Simple mock: return all credentials, + // let presentations.js do the filtering + return {documents: mockCredentials}; + }, + convertVPRQuery: async () => { + // Mock conversion - return a simple query + return {queries: [{}]}; + } + } + }; + + // Create VPR that should match only the university degree credential + const verifiablePresentationRequest = { + query: { + type: 'QueryByExample', + credentialQuery: { + example: { + type: 'UniversityDegreeCredential', + credentialSubject: { + degree: { + type: 'BachelorDegree' + } + } + } + } + } + }; + + // Call presentations.match() - this should trigger your batch processing! + const {flat: matches} = await webWallet.presentations.match({ + verifiablePresentationRequest, + credentialStore: mockCredentialStore + }); + + // Verify results + matches.should.have.length(1); + matches[0].record.content.credentialSubject.name.should.equal('Alice'); + matches[0].record.content.type + .should.include('UniversityDegreeCredential'); + }); +}); diff --git a/test/web/15-query-by-example.js b/test/web/15-query-by-example.js index ffc4efa..0691122 100644 --- a/test/web/15-query-by-example.js +++ b/test/web/15-query-by-example.js @@ -117,5 +117,41 @@ describe('queryByExample', function() { matches[0].credentialSubject.degree.name .should.equal('Bachelor of Science'); }); + + it('should verify batch processing integration', async function() { + // Test module can handle the mock data structure used in presentations.js + const mockMatches = [ + { + record: { + content: { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + credentialSubject: {degree: {type: 'BachelorDegree'}} + } + } + }, + { + record: { + content: { + type: ['VerifiableCredential', 'DriverLicense'], + credentialSubject: {name: 'Test'} + } + } + } + ]; + + // Simulate what presentations.js does + const allContents = mockMatches.map(match => match.record.content); + const credentialQuery = { + example: { + type: 'UniversityDegreeCredential' + } + }; + + const matchingContents = matchCredentials(allContents, credentialQuery); + + // Should find 1 matching credential + matchingContents.should.have.length(1); + matchingContents[0].type.should.include('UniversityDegreeCredential'); + }); }); }); From c8fad33eda6e04dbd17d9114e8b32ae9048a5265 Mon Sep 17 00:00:00 2001 From: Parth Bhatt Date: Sun, 7 Sep 2025 18:44:06 -0700 Subject: [PATCH 4/4] Add Query By Example (QBE) credential matching with semantic features. - Implements comprehensive QBE matching system with matchCredentials() function supporting semantic match types (mustBeNull, anyArray, anyValue, exactMatch) - Adds selectiveDisclosureUtil.js with JSON-LD selection and pointer utilities including selectJsonLd(), adjustPointers(), and parsePointer() functions - Integrates array containment matching vs positional matching for flexible credential queries supporting overlay matching where credentials can have extra fields beyond the example specification - Handles null value vs missing field distinction with strict semantic rules - Includes string normalization and type coercion for robust value comparison - Integrates with existing presentations.match() system while maintaining backward compatibility with existing query types - Adds debug.js utility to conditionally enable/disable console logs during development with support for environment variables and browser flags - Provides comprehensive test suite with 45+ tests covering semantic features, edge cases, array matching, nested objects, and real-world scenarios --- lib/config.js | 3 + lib/debug.js | 28 ++ lib/presentations.js | 8 +- lib/queryByExample.js | 592 +++++++++++++++++++++- lib/selectiveDisclosureUtil.js | 252 ++++++++++ package.json | 1 + test/web/10-api.js | 139 +++--- test/web/15-query-by-example.js | 841 +++++++++++++++++++++++++++----- test/web/mock-data.js | 200 +++++++- 9 files changed, 1871 insertions(+), 193 deletions(-) create mode 100644 lib/debug.js create mode 100644 lib/selectiveDisclosureUtil.js diff --git a/lib/config.js b/lib/config.js index 189ebf4..934ccef 100644 --- a/lib/config.js +++ b/lib/config.js @@ -135,5 +135,8 @@ config.wallet = { // only derived proof is allowed in presentation proofValuePrefix: 'u2V0Dh' }] + }, + debug: { + queryByExample: false // Default to false, let debug.js handle detection } }; diff --git a/lib/debug.js b/lib/debug.js new file mode 100644 index 0000000..939f776 --- /dev/null +++ b/lib/debug.js @@ -0,0 +1,28 @@ +import {config} from '@bedrock/web'; + +const isDebugEnabled = () => { + // Check config first + if(config.wallet?.debug?.queryByExample) { + return true; + } + + // Node.js environment + if(typeof process !== 'undefined' && + process.env?.DEBUG_QUERY_BY_EXAMPLE === 'true') { + return true; + } + + // Browser environment + if(typeof window !== 'undefined' && + window.DEBUG_QUERY_BY_EXAMPLE === true) { + return true; + } + + return false; +}; + +export function debugLog(...args) { + if(isDebugEnabled()) { + console.log('[QBE DEBUG]', ...args); + } +} diff --git a/lib/presentations.js b/lib/presentations.js index 83219f2..c4c079b 100644 --- a/lib/presentations.js +++ b/lib/presentations.js @@ -9,6 +9,7 @@ import {createDiscloseCryptosuite as createBbsDiscloseCryptosuite} from import {createDiscloseCryptosuite as createEcdsaSdDiscloseCryptosuite} from '@digitalbazaar/ecdsa-sd-2023-cryptosuite'; import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; +import {debugLog} from './debug.js'; import {documentLoader} from './documentLoader.js'; import {ensureLocalCredentials} from './ageCredentialHelpers.js'; import jsonpointer from 'json-pointer'; @@ -397,7 +398,9 @@ async function _getMatches({ if(credentialQuery?.example) { const allContents = result.matches.map(match => match.record.content); - const matchingContents = matchCredentials(allContents, credentialQuery); + const matchingContents = matchCredentials({ + credentials: allContents, queryByExample: credentialQuery + }); // Map results back to original records using reference comparison result.matches = result.matches.filter(match => @@ -474,6 +477,9 @@ function _matchContextFilter({credentialQuery}) { async function _matchQueryByExample({ verifiablePresentationRequest, query, credentialStore, matches }) { + debugLog('_matchQueryByExample called with query type:', + query.type); // DEBUG + matches.push(...await _getMatches({ verifiablePresentationRequest, vprQuery: query, credentialStore })); diff --git a/lib/queryByExample.js b/lib/queryByExample.js index ab3c567..5d11fb3 100644 --- a/lib/queryByExample.js +++ b/lib/queryByExample.js @@ -1,9 +1,88 @@ /*! - * Copyright (c) 2018-2025 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. */ +import {adjustPointers, selectJsonLd} + from './selectiveDisclosureUtil.js'; +import {debugLog} from './debug.js'; import JsonPointer from 'json-pointer'; /** + * Matches credentials against a Query By Example specification. + * This function processes the full QueryByExample matching on a list of VCs + * that have already been preliminarily filtered (e.g., by top-level type). + * + * @param {object} options - The options. + * @param {Array} options.credentials - Array of credential objects to + * match against. + * @param {object} options.queryByExample - The Query By Example specification. + * @param {object} options.queryByExample.example - The example credential + * structure to match against. + * + * @returns {Array} Array of credentials that match the Query By Example + * specification. + */ +export function matchCredentials({credentials, queryByExample} = {}) { + const {example} = queryByExample || {}; + if(!(example && typeof example === 'object')) { + // no example to match against, return all credentials + return credentials || []; + } + + // Input validation - filter out invalid credentials + if(!Array.isArray(credentials)) { + debugLog('Credentials is not an array:', credentials); // DEBUG + return []; + } + + debugLog('Input credentials count:', credentials.length); // DEBUG + debugLog('Input credentials:', credentials.map(c => ({ // DEBUG + type: c?.type, + name: c?.credentialSubject?.name, + hasCredentialSubject: !!c?.credentialSubject + }))); + + // Filter out invalid individual credentials + const validCredentials = credentials.filter(credential => { + const isValid = credential && + typeof credential === 'object' && + !Array.isArray(credential) && + credential.credentialSubject && + typeof credential.credentialSubject === 'object'; + + if(!isValid) { + debugLog('Filtered out invalid credential:', credential); // DEBUG + } + return isValid; + }); + + debugLog('Valid credentials count:', validCredentials.length); // DEBUG + + debugLog('Example:', example); // DEBUG + + // Convert example to deepest pointers with expected values + const pointerValuePairs = convertExampleToPointers({example}); + + debugLog('Pointer value pairs:', pointerValuePairs); // DEBUG + + if(pointerValuePairs.length === 0) { + return validCredentials; + } + + return validCredentials.filter(credential => { + debugLog('About to test credential:', + credential.credentialSubject?.name); // DEBUG + + const match = _credentialMatches({credential, pointerValuePairs}); + + debugLog('Credential match result:', match, + 'for:', credential.credentialSubject?.name); // DEBUG + return match; + }); +} + +/** + * NOT IN USE - Version 0 - use matchCredentials instead. + * * Matches credentials against a Query By Example specification. * This function processes the full QueryByExample matching on a list of VCs * that have already been preliminarily filtered (e.g., by top-level type). @@ -16,7 +95,7 @@ import JsonPointer from 'json-pointer'; * @returns {Array} Array of credentials that match the Query By Example * specification. */ -export function matchCredentials(credentials, queryByExample) { +export function matchCredentials_v0(credentials, queryByExample) { const {example} = queryByExample; if(!(example && typeof example === 'object')) { // no example to match against, return all credentials @@ -42,7 +121,7 @@ export function matchCredentials(credentials, queryByExample) { return result; } catch(e) { // If pointer doesn't exist in credential, it's not a match - console.log('Pointer error:', pointer, e.message); + debugLog('Pointer error:', pointer, e.message); return false; } }); @@ -50,6 +129,183 @@ export function matchCredentials(credentials, queryByExample) { } /** + * Converts a Query By Example to JSON pointers with expected values. + * This function can be used by presentation.js and other modules for + * selective disclosure and pointer-based operations. + * + * @param {object} options - The options. + * @param {object} options.example - The example object from Query By Example. + * @param {object} [options.options={}] - Conversion options. + * @param {boolean} [options.options.includeContext=true] - Whether to include + * context field matching. + * + * @returns {Array} Array of objects with + * {pointer, expectedValue, matchType} + * where pointer is a JSON pointer string, expectedValues is the + * expected value, and matchType describes how to match + * ('exactMatch', 'anyArray', etc.). + */ +export function convertExampleToPointers({example, options = {}} = {}) { + if(!(example && typeof example === 'object')) { + return []; + } + + const {includeContext = true} = options; + const pointerValuePairs = []; + + // Prepare example for processing + const processedExample = {...example}; + + // Handle @context based on options + if(!includeContext) { + delete processedExample['@context']; + } + + debugLog('Processed example:', processedExample); // DEBUG + + try { + // Convert to JSON pointer dictionary (reuse existing approach) + const dict = JsonPointer.dict(processedExample); + debugLog('JSON pointer dict:', dict); // DEBUG + + // Process arrays to convert indexed pointers to array-level pointers + const processedDict = _processArraysInDict(dict); + debugLog('Processed dict with array handling:', processedDict); + + // WORKAROUND: JsonPointer.dict() filters out empty arrays/objects + // We need to manually find them since they're wildcards in our system + const additionalPointers = _findEmptyValuesPointers(processedExample); + debugLog('Additional pointers for empty values:', + additionalPointers); // DEBUG + + // Extract pointer/value pairs with match types + const allPointers = new Set(); // Prevent duplicates + + for(const [pointer, value] of Object.entries(processedDict)) { + if(!allPointers.has(pointer)) { // Check for duplicates + const matchType = _determineMatchType(value); + + debugLog('Pointer:', pointer, 'Value:', + value, 'MatchType:', matchType); // DEBUG + + // Include all pointer/value pairs (even 'ignore' type for completeness) + pointerValuePairs.push({ + pointer, + expectedValue: value, + matchType + }); + allPointers.add(pointer); + } + } + + // Add the empty arrays/objects that JsonPointer.dict() missed + for(const {pointer, value} of additionalPointers) { + if(!allPointers.has(pointer)) { // Check for duplicates + const matchType = _determineMatchType(value); + + debugLog('Additional Pointer:', pointer, 'Value:', + value, 'MatchType:', matchType); // DEBUG + + pointerValuePairs.push({ + pointer, + expectedValue: value, + matchType + }); + + allPointers.add(pointer); + } + + } + } catch(e) { + // If JSON pointer conversion fails, return empty array + console.warn('Failed to convert example to JSON pointers:', e); + return []; + } + + debugLog('Before adjustPointers:', pointerValuePairs); // DEBUG + + // Apply pointer adjustments (use deepest pointers, handle credentialSubject) + // This ensures compatibility with selective disclosure approach + const rawPointers = pointerValuePairs.map(pair => pair.pointer); + const deepestPointers = adjustPointers(rawPointers); + + debugLog('Raw pointers:', rawPointers); // DEBUG + debugLog('Deepest pointers:', deepestPointers); // DEBUG + + // Filter to only include adjusted (deepest) pointers + const finalPairs = pointerValuePairs.filter(pair => + deepestPointers.includes(pair.pointer) + ); + + debugLog('Final pairs:', finalPairs); // DEBUG + + return finalPairs; +} + +function _findEmptyValuesPointers(obj, basePath = '') { + const pointers = []; + + for(const [key, value] of Object.entries(obj)) { + const currentPath = basePath + '/' + key; + + if(Array.isArray(value) && value.length === 0) { + // Empty array - add it + pointers.push({pointer: currentPath, value}); + } else if(typeof value === 'object' && value !== null && + Object.keys(value).length === 0) { + // Empty object - add it + pointers.push({pointer: currentPath, value}); + } else if(value === null) { + // Null value - add it + pointers.push({pointer: currentPath, value}); + } else if(typeof value === 'object' && value !== null) { + // Recurse into nested objects + pointers.push(..._findEmptyValuesPointers(value, currentPath)); + } + } + + return pointers; +} + +// Convert array element pointers to array-level pointers +function _processArraysInDict(dict) { + const processed = {}; + const arrayGroups = {}; + + // Group array element pointers + for(const [pointer, value] of Object.entries(dict)) { + const arrayMatch = pointer.match(/^(.+)\/(\d+)$/); + if(arrayMatch) { + const [, arrayPath, index] = arrayMatch; + + // Skip @context arrays - keep them as individual elements + if(arrayPath === '/@context') { + processed[pointer] = value; // Keep original pointer like /@context/0 + continue; + } + + // Process other arrays normally + if(!arrayGroups[arrayPath]) { + arrayGroups[arrayPath] = []; + } + arrayGroups[arrayPath][parseInt(index)] = value; + } else { + processed[pointer] = value; + } + } + + // Convert array groups to array-level pointers (excluding @context) + for(const [arrayPath, elements] of Object.entries(arrayGroups)) { + const denseArray = elements.filter(el => el !== undefined); + processed[arrayPath] = denseArray; + } + + return processed; +} + +/** + * NOT IN USE - Version 0 - use convertExampleToPointers instead. + * * Converts an example to an array of JSON pointer/value pairs. * This function recursively processes the example object to extract all * field paths and their expected values, excluding @context which is @@ -118,6 +374,8 @@ function _isMatchableValue(value) { } /** + * NOT IN USE - Version 0 - use _valuesMatch instead. + * * Determines if an actual value from a credential matches an expected value * from a Query By Example specification. This handles various matching * scenarios including arrays, different types, and normalization. @@ -128,7 +386,8 @@ function _isMatchableValue(value) { * @returns {boolean} True if the values match according to Query By Example * matching rules. */ -function _valuesMatch(actualValue, expectedValue) { +/* +function _valuesMatch_v0(actualValue, expectedValue) { // Handle null/undefined cases if(actualValue == null && expectedValue == null) { return true; @@ -145,13 +404,13 @@ function _valuesMatch(actualValue, expectedValue) { // If actual is array but expected is single value, check if array // contains the value if(Array.isArray(actualValue) && !Array.isArray(expectedValue)) { - return actualValue.some(item => _valuesMatch(item, expectedValue)); + return actualValue.some(item => _valuesMatch_v0(item, expectedValue)); } // If expected is array but actual is single value, check if actual // is in expected if(!Array.isArray(actualValue) && Array.isArray(expectedValue)) { - return expectedValue.some(item => _valuesMatch(actualValue, item)); + return expectedValue.some(item => _valuesMatch_v0(actualValue, item)); } // For objects, do deep equality comparison @@ -162,6 +421,7 @@ function _valuesMatch(actualValue, expectedValue) { // For primitive values, do strict equality with string normalization return _primitiveValuesMatch(actualValue, expectedValue); } +*/ /** * Checks if two arrays have any common elements. @@ -173,11 +433,13 @@ function _valuesMatch(actualValue, expectedValue) { */ function _arraysHaveCommonElements(arr1, arr2) { return arr1.some(item1 => - arr2.some(item2 => _valuesMatch(item1, item2)) + arr2.some(item2 => _valuesMatchExact(item1, item2)) ); } /** + * NOT IN USE - Version 0 - use _objectsMatchOverlay instead. + * * Performs deep equality comparison for objects. * * @param {object} obj1 - First object. @@ -185,6 +447,7 @@ function _arraysHaveCommonElements(arr1, arr2) { * * @returns {boolean} True if objects are deeply equal. */ +/* function _objectsMatch(obj1, obj2) { const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); @@ -196,9 +459,10 @@ function _objectsMatch(obj1, obj2) { // Check if all keys and values match return keys1.every(key => - keys2.includes(key) && _valuesMatch(obj1[key], obj2[key]) + keys2.includes(key) && _valuesMatch_v0(obj1[key], obj2[key]) ); } +*/ /** * Compares primitive values (string, numbers, booleans) with appropriate @@ -230,3 +494,315 @@ function _primitiveValuesMatch(actual, expected) { // No match return false; } + +// ============================================================================= +// TODO: IMPLEMENT CORE MATCHING FUNCTIONS +// ============================================================================= + +/** + * Tests if a credential matches using selective disclosure approach. + * + * @param {object} root0 - The options object. + * @param {object} root0.credential - The credential to test. + * @param {Array} root0.pointerValuePairs - Array of pointer/value + * pairs to match from convertExampleToPointers(). + * + * @returns {boolean} True if the credential matches, false otherwise. + */ +function _credentialMatches({credential, pointerValuePairs}) { + + debugLog('Testing credential:', + credential.credentialSubject?.name); // DEBUG + + debugLog('Pointers to test:', pointerValuePairs.map(p => ({ + pointer: p.pointer, + expectedValue: p.expectedValue, + matchType: p.matchType + }))); // DEBUG + + // Separate null checking from structural checking + const nullPairs = pointerValuePairs.filter(pair => + pair.matchType === 'mustBeNull'); + + // Filter out 'ignore' type pointers since they don't affect structure + const structuralPairs = pointerValuePairs.filter(pair => + pair.matchType != 'ignore' && pair.matchType !== 'mustBeNull' + ); + + // Handle mustBeNull pairs directly without selectJsonLd + for(const pair of nullPairs) { + const {pointer} = pair; + debugLog('Checking null field:', pointer); // DEBUG + try { + const actualValue = _getValueByPointer(credential, pointer); + debugLog('Actual value for', pointer, ':', actualValue); // DEBUG + + // For mustBeNull: actualValue must be EXPLICITLY null (not undefined) + if(actualValue !== null) { + debugLog('Field is not explicitly null, no match'); // DEBUG + return false; + } + } catch(error) { + // If pointer doesn't exist (undefined), it + // doesn't match explicit null + debugLog('Pointer not found (undefined), does not' + + 'match explicit null'); // DEBUG + } + } + + // Only use selectJsonLd for non-null structural validation + if(structuralPairs.length === 0) { + // No structural requirements, so any credential matches + return true; + } + + try { + // Extract just the pointers for structure testing + const pointers = pointerValuePairs.map(pair => pair.pointer); + debugLog('Calling selectJsonLd with pointers:', pointers); // DEBUG + + // Use selectJsonLd to test if the credential has the required structure + const selection = selectJsonLd({ + document: credential, + pointers, + includeTypes: true + }); + + debugLog('selectJsonLd result:', selection); // DEBUG + + if(!selection) { + // Structure doesn't match - selectJsonLd returned null + debugLog('selectJsonLd returned null - no structural match'); // DEBUG + return false; + } + + // Structure matches, now validate the selected values + const result = _validateSelectedValues({selection, pointerValuePairs}); + debugLog('_validateSelectedValues result:', result); // DEBUG + return result; + + } catch(e) { + // Pointer structure doesn't match document (TypeError from selectJsonLd) + debugLog('selectJsonLd threw error:', e.message); // DEBUG + return false; + } +} + +/** + * Validates selected values against expected values with match types. + * Called after structural matching succeeds. + * + * @param {object} root0 - The options object. + * @param {object} root0.selection - The selected values from the credential. + * @param {Array} root0.pointerValuePairs - Array of pointer/value + * pairs to match from convertExampleToPointers(). + * + * @returns {boolean} True if all selected values match the expected values, + * false otherwise. + */ +function _validateSelectedValues({selection, pointerValuePairs}) { + debugLog('_validateSelectedValues called with:'); // DEBUG + debugLog('Selection:', selection); // DEBUG + debugLog('PointerValuePairs:', pointerValuePairs); // DEBUG + + // Check each pointer-value pair against the selection + return pointerValuePairs.every(({pointer, expectedValue, matchType}) => { + try { + // Extract the actual value from the selection using the pointer + const actualValue = _getValueByPointer(selection, pointer); + + debugLog('Validating pointer:', pointer); // DEBUG + debugLog('Expected value:', expectedValue, 'type:', + typeof expectedValue); // DEBUG + debugLog('Actual value:', actualValue, + 'type:', typeof actualValue); // DEBUG + + // Use enhanced value matching with match type + const result = _valuesMatch(actualValue, expectedValue, matchType); + debugLog('_valuesMatch result:', result); // DEBUG + return result; + + } catch(error) { + // If can't get the value, it doesn't match + debugLog('Error in _validateSelectedValues:', error.message); // DEBUG + return false; + } + }); +} + +/** + * Gets a value from an object using a JSON pointer string. + * Simple implementation for extracting values from selection. + * + * @param {object} obj - The object to extract the value from. + * @param {string} pointer - The JSON pointer string + * (e.g., "/credentialSubject/name"). + * @returns {*} The value found at the pointer location, or + * undefined if not found. + */ +function _getValueByPointer(obj, pointer) { + if(pointer === '') { + return obj; + } + + const paths = pointer.split('/').slice(1); // Remove empty first element + let current = obj; + + for(const path of paths) { + if(current == null) { + return undefined; + } + + // Handle array indices + const index = parseInt(path, 10); + const key = isNaN(index) ? path : index; + + current = current[key]; + } + return current; +} + +/** + * Determines the match type for a value based on new semantic rules. + * This implements the enhanced semantics from Feedback 4-6. + * + * @param {*} value - The value from the Query By Example to analyze. + * + * @returns {string} The match type: + * - 'ignore': undefined values (property wasn't specified). + * - 'mustBeNull': null values (credential field must be null/missing). + * - 'anyArray': empty arrays (credential must have any array). + * - 'anyValue': empty objects (credential can have any value). + * - 'exactMatch': all other values (credential must match this value). + */ +function _determineMatchType(value) { + // undefined means "ignore this field" - property wasn't specified in example + if(value === undefined) { + return 'ignore'; + } + + // null means "credential field must be null or missing" + if(value === null) { + return 'mustBeNull'; + } + + // Empty array means "credential must have any array" (wildcard) + if(Array.isArray(value) && value.length === 0) { + return 'anyArray'; + } + + // Empty object means "credential can have any value" (wildcard) + if(typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).length === 0) { + return 'anyValue'; + } + + // All other values require exact matching + return 'exactMatch'; +} + +/** + * Enhanced value matching with match type support. + * Implements new semantic rules and overlay matching approach. + * + * @param {*} actual - The actual value from the credential. + * @param {*} expected - The expected value from the example. + * @param {string} [matchType='exactMatch'] - The match type to use. + * + * @returns {boolean} True if the values match according to the match type. + */ +function _valuesMatch(actual, expected, matchType = 'exactMatch') { + switch(matchType) { + case 'ignore': + // Always match - this field should be ignored + return true; + case 'mustBeNull': + // Credential field must be null or missing + return actual == null; + case 'anyArray': + // Credential must have any array + return Array.isArray(actual); + case 'anyValue': + // Credential can have any value (wildcard) + return true; + case 'exactMatch': + // Use enhanced comparison logic with overlay matching + return _valuesMatchExact(actual, expected); + default: + console.warn(`Unknown match type: ${matchType}`); + return false; + } +} + +/** + * Enhanced exact value matching with overlay approach. + * Implements the "overlay" concept. + * + * @param {*} actual - The actual value from the credential. + * @param {*} expected - The expected value from the example. + * + * @returns {boolean} True if values match using overlay rules. + */ +function _valuesMatchExact(actual, expected) { + debugLog('_valuesMatchExact called:', {actual, expected}); // DEBUG + // Handle null/undefined cases + if(actual == null && expected == null) { + return true; + } + + if(actual == null || expected == null) { + return false; + } + + // If both are arrays, check if they have common elements + if(Array.isArray(actual) && Array.isArray(expected)) { + return _arraysHaveCommonElements(actual, expected); + } + + // If actual is array but expected is single value, check if array + // contains the value + if(Array.isArray(actual) && !Array.isArray(expected)) { + debugLog('Array vs single value - checking containment'); // DEBUG + const result = actual.some(item => _valuesMatchExact(item, expected)); + debugLog('Containment result:', result); // DEBUG + return result; + } + + // If expected is array but actual is single value, check if actual + // is in expected + if(!Array.isArray(actual) && Array.isArray(expected)) { + return expected.some(item => _valuesMatchExact(actual, item)); + } + + // For objects, do overlay matching (not exact equality) + if(typeof actual === 'object' && typeof expected === 'object') { + return _objectsMatchOverlay(actual, expected); + } + + // For primitive values, do strict equality with string normalization + return _primitiveValuesMatch(actual, expected); +} + +/** + * Overlay object matching - checks if expected fields exist in actual object. + * The actual object can have additional fields (overlay approach). + * + * @param {object} actual - Actual object from credential. + * @param {object} expected - Expected object from example. + * + * @returns {boolean} True if all expected fields match actual fields. + */ +function _objectsMatchOverlay(actual, expected) { + const expectedKeys = Object.keys(expected); + + // Check if all expected keys and values exist and match in actual object + return expectedKeys.every(key => { + // Expected key must exist in actual object + if(!(key in actual)) { + return false; + } + + // Recursively check the values match using overlay approach + return _valuesMatchExact(actual[key], expected[key]); + }); +} diff --git a/lib/selectiveDisclosureUtil.js b/lib/selectiveDisclosureUtil.js new file mode 100644 index 0000000..7fb9ab9 --- /dev/null +++ b/lib/selectiveDisclosureUtil.js @@ -0,0 +1,252 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +import {klona} from 'klona'; + +// Adapted from @digitalbazaar/di-sd-primitives/lib/pointer.js. +// JSON pointer escape sequences +// ~0 => '~' +// ~1 => '/' +const POINTER_ESCAPE_REGEX = /~[01]/g; + +/** + * Selects JSON-LD using JSON pointers to create a selection document. + * Adapted from @digitalbazaar/di-sd-primitives/lib/select.js. + * + * @param {object} options - The options. + * @param {object} options.document - The JSON-LD document to select from. + * @param {Array} options.pointers - Array of JSON pointer strings. + * @param {boolean} [options.includeTypes=true] - Whether to include type + * information. + * + * @returns {object|null} The selection document or null if no selection + * possible. + */ +export function selectJsonLd({document, pointers, includeTypes = true} = {}) { + if(!(document && typeof document === 'object')) { + throw new TypeError('"document" must be an object.'); + } + if(!Array.isArray(pointers)) { + throw new TypeError('"pointers" must be an array.'); + } + if(pointers.length === 0) { + // no pointers, so no frame + return null; + } + + // track arrays to make them dense after selection + const arrays = []; + // perform selection + const selectionDocument = {'@context': klona(document['@context'])}; + _initSelection( + {selection: selectionDocument, source: document, includeTypes}); + for(const pointer of pointers) { + // parse pointer into individual paths + const paths = parsePointer(pointer); + if(paths.length === 0) { + // whole document selected + return klona(document); + } + _selectPaths({ + document, pointer, paths, selectionDocument, arrays, includeTypes + }); + } + + // make any sparse arrays dense + for(const array of arrays) { + let i = 0; + while(i < array.length) { + if(array[i] === undefined) { + array.splice(i, 1); + continue; + } + i++; + } + } + + return selectionDocument; +} + +/** + * Parses a JSON pointer string into an array of paths. + * Adapted from @digitalbazaar/di-sd-primitives/lib/pointer.js. + * + * @param {string} pointer - JSON pointer string (e.g., '/foo/bar/0'). + * + * @returns {Array} Array of path components (strings and numbers for + * array indices). + */ +export function parsePointer(pointer) { + // see RFC 6901: https://www.rfc-editor.org/rfc/rfc6901.html + const parsed = []; + const paths = pointer.split('/').slice(1); + for(const path of paths) { + if(!path.includes('~')) { + // convert any numerical path to a number as an array index + const index = parseInt(path, 10); + parsed.push(isNaN(index) ? path : index); + } else { + parsed.push(path.replace(POINTER_ESCAPE_REGEX, _unescapePointerPath)); + } + } + return parsed; +} + +/** + * Adjusts pointers to ensure proper credential structure and + * gets deepest pointers. + * Adapted from presentations.js (_adjustPointers) for reusability. + * TODO: use this function in presentations.js. + * + * @param {Array} pointers - Array of JSON pointer strings. + * + * @returns {Array} Array of adjusted pointer strings. + */ +export function adjustPointers(pointers) { + // ensure `credentialSubject` is included in any reveal, presume that if + // it isn't present that the entire credential subject was requested + const hasCredentialSubject = pointers.some( + pointer => pointers.includes('/credentialSubject/') || + pointer.endsWith('/credentialSubject')); + if(!hasCredentialSubject) { + pointers = pointers.slice(); + pointers.push('/credentialSubject'); + } + + pointers = pruneShallowPointers(pointers); + + // make `type` pointers generic + return pointers.map(pointer => { + const index = pointer.indexOf('/type/'); + return index === -1 ? pointer : pointer.slice(0, index) + '/type'; + }); +} + +/** + * Gets only the deepest pointers from the given list of pointers. + * For example, `['/a/b', '/a/b/c', '/a/b/c/d']` will be + * pruned to: `['/a/b/c/d']`. + * Adapted from presentations.js (_pruneShallowPointers) for reusability. + * TODO: use this function in presentations.js. + * + * @param {Array} pointers - Array of JSON pointer strings. + * + * @returns {Array} Array of deepest pointer strings. + */ +export function pruneShallowPointers(pointers) { + const deep = []; + for(const pointer of pointers) { + let isDeep = true; + for(const p of pointers) { + if(pointer.length < p.length && p.startsWith(pointer)) { + isDeep = false; + break; + } + } + if(isDeep) { + deep.push(pointer); + } + } + return deep; +} + +// ============================================================================= +// INTERNAL HELPER FUNCTIONS +// ============================================================================= + +/** + * Helper for selectJsonLd - selects paths in the document. + * Adapted from @digitalbazaar/di-sd-primitives/lib/select.js. + * + * @param {object} root0 - The options object. + * @param {object} root0.document - The source JSON-LD document. + * @param {string} root0.pointer - The JSON pointer string. + * @param {Array} root0.paths - The parsed pointer paths. + * @param {object} root0.selectionDocument - The selection document being built. + * @param {Array} root0.arrays - Array tracker for dense arrays. + * @param {boolean} root0.includeTypes - Whether to include type information. + */ +function _selectPaths({ + document, pointer, paths, selectionDocument, arrays, includeTypes} = {}) { + // make pointer path in selection document + let parentValue = document; + let value = parentValue; + let selectedParent = selectionDocument; + let selectedValue = selectedParent; + + for(const path of paths) { + selectedParent = selectedValue; + parentValue = value; + // get next document value + value = parentValue[path]; + if(value === undefined) { + throw new TypeError( + `JSON pointer "${pointer}" does not match document.`); + } + // get next value selection + selectedValue = selectedParent[path]; + if(selectedValue === undefined) { + if(Array.isArray(value)) { + selectedValue = []; + arrays.push(selectedValue); + } else { + selectedValue = _initSelection({source: value, includeTypes}); + } + selectedParent[path] = selectedValue; + } + } + + // path traversal complete, compute selected value + if(typeof value !== 'object') { + // literal selected + selectedValue = value; + } else if(Array.isArray(value)) { + // full array selected + selectedValue = klona(value); + } else { + // object selected, blend with `id` / `type` / `@context` + selectedValue = {...selectedValue, ...klona(value)}; + } + + // add selected value to selected parent + selectedParent[paths.at(-1)] = selectedValue; +} + +/** + * Helper for selectJsonLd - initializes selection with id/type. + * Adapted from @digitalbazaar/di-sd-primitives/lib/select.js. + * + * @param {object} root0 - The options object. + * @param {object} [root0.selection={}] - The selection object to initialize. + * @param {object} root0.source - The source object to select from. + * @param {boolean} root0.includeTypes - Whether to include type information. + * @returns {object} The initialized selection object. + */ +function _initSelection({selection = {}, source, includeTypes}) { + // must include non-blank node IDs + if(source.id && !source.id.startsWith('_:')) { + selection.id = source.id; + } + // include types if directed to do so + if(includeTypes && source.type) { + selection.type = source.type; + } + return selection; +} + +/** + * Unescapes JSON pointer path components. + * Adapted from @digitalbazaar/di-sd-primitives/lib/pointer.js. + * + * @param {string} m - The escape sequence to unescape. + * @returns {string} The unescaped path component. + */ +function _unescapePointerPath(m) { + if(m === '~1') { + return '/'; + } + if(m === '~0') { + return '~'; + } + throw new Error(`Invalid JSON pointer escape sequence "${m}".`); +} diff --git a/package.json b/package.json index a909dac..3fa3f6b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "ed25519-signature-2018-context": "^1.1.0", "json-pointer": "^0.6.2", "jsonld-signatures": "^11.3.0", + "klona": "^2.0.6", "p-all": "^5.0.0", "p-map": "^7.0.2", "uuid": "^10.0.0" diff --git a/test/web/10-api.js b/test/web/10-api.js index 14866c3..abd2340 100644 --- a/test/web/10-api.js +++ b/test/web/10-api.js @@ -301,85 +301,86 @@ describe('presentations.sign()', function() { }); describe('presentations.match()', function() { - it('should match credentials using Query By Example with batch processing', - async () => { + it('should match credentials using Query By Example with batch ' + + 'processing', + async () => { - // Create a mock credential store with test credentials - const mockCredentialStore = { - local: { - // Empty local store for simplicity - find: async () => ({documents: []}) - }, - remote: { - find: async () => { - // Return mock credentials that match our test - const mockCredentials = [ - { - content: { - '@context': ['https://www.w3.org/2018/credentials/v1'], - type: ['VerifiableCredential', 'UniversityDegreeCredential'], - credentialSubject: { - id: 'did:example:test1', - name: 'Alice', - degree: { - type: 'BachelorDegree', - name: 'Bachelor of Computer Science' - } + // Create a mock credential store with test credentials + const mockCredentialStore = { + local: { + // Empty local store for simplicity + find: async () => ({documents: []}) + }, + remote: { + find: async () => { + // Return mock credentials that match our test + const mockCredentials = [ + { + content: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + credentialSubject: { + id: 'did:example:test1', + name: 'Alice', + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Computer Science' } - }, - meta: {id: 'credential-1'} + } }, - { - content: { - '@context': ['https://www.w3.org/2018/credentials/v1'], - type: ['VerifiableCredential', 'DriverLicense'], - credentialSubject: { - id: 'did:example:test2', - name: 'Bob' - } - }, - meta: {id: 'credential-2'} - } - ]; + meta: {id: 'credential-1'} + }, + { + content: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'DriverLicense'], + credentialSubject: { + id: 'did:example:test2', + name: 'Bob' + } + }, + meta: {id: 'credential-2'} + } + ]; - // Simple mock: return all credentials, - // let presentations.js do the filtering - return {documents: mockCredentials}; - }, - convertVPRQuery: async () => { - // Mock conversion - return a simple query - return {queries: [{}]}; - } + // Simple mock: return all credentials, + // let presentations.js do the filtering + return {documents: mockCredentials}; + }, + convertVPRQuery: async () => { + // Mock conversion - return a simple query + return {queries: [{}]}; } - }; + } + }; - // Create VPR that should match only the university degree credential - const verifiablePresentationRequest = { - query: { - type: 'QueryByExample', - credentialQuery: { - example: { - type: 'UniversityDegreeCredential', - credentialSubject: { - degree: { - type: 'BachelorDegree' - } + // Create VPR that should match only the university degree credential + const verifiablePresentationRequest = { + query: { + type: 'QueryByExample', + credentialQuery: { + example: { + type: 'UniversityDegreeCredential', + credentialSubject: { + degree: { + type: 'BachelorDegree' } } } } - }; - - // Call presentations.match() - this should trigger your batch processing! - const {flat: matches} = await webWallet.presentations.match({ - verifiablePresentationRequest, - credentialStore: mockCredentialStore - }); + } + }; - // Verify results - matches.should.have.length(1); - matches[0].record.content.credentialSubject.name.should.equal('Alice'); - matches[0].record.content.type - .should.include('UniversityDegreeCredential'); + // Call presentations.match() - this should trigger your batch processing! + const {flat: matches} = await webWallet.presentations.match({ + verifiablePresentationRequest, + credentialStore: mockCredentialStore }); + + // Verify results + matches.should.have.length(1); + matches[0].record.content.credentialSubject.name.should.equal('Alice'); + matches[0].record.content.type + .should.include('UniversityDegreeCredential'); + }); }); diff --git a/test/web/15-query-by-example.js b/test/web/15-query-by-example.js index 0691122..a2de11a 100644 --- a/test/web/15-query-by-example.js +++ b/test/web/15-query-by-example.js @@ -1,157 +1,770 @@ /*! * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. */ +import {edgeCaseCredentials, mockCredential, mockCredentials} + from './mock-data.js'; import {queryByExample} from '@bedrock/web-wallet'; -const {matchCredentials} = queryByExample; +const {matchCredentials, convertExampleToPointers} = queryByExample; describe('queryByExample', function() { + describe('matchCredentials()', function() { - const mockCredentials = [ - { - '@context': ['https://www.w3.org/2018/credentials/v1'], - type: ['VerifiableCredential', 'UniversityDegreeCredential'], - credentialSubject: { - id: 'did:example:123', - name: 'John Doe', - degree: { - type: 'BachelorDegree', - name: 'Bachelor of Science' - }, - alumniOf: { - name: 'University of Example' - } - } - }, - { - '@context': ['https://www.w3.org/2018/credentials/v1'], - type: ['VerifiableCredential', 'DriverLicense'], - credentialSubject: { - id: 'did:example:456', - name: 'Jane Smith', - licenseClass: 'A' - } - } - ]; - - it('should match credentials by single field', async function() { - const query = { - example: { - credentialSubject: { - name: 'John Doe' + + describe('API and Basic Functionality', function() { + it('should use named parameters API', function() { + const queryByExample = { + example: { + credentialSubject: {name: 'John Doe'} } - } - }; + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + }); + + it('should return all credentials when no example provided', function() { + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample: {} + }); + + matches.should.have.length(5); // All 5 mock credentials + }); + + it('should return all credentials when example is null', function() { + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample: {example: null} + }); + + matches.should.have.length(5); + }); - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(1); - matches[0].credentialSubject.name.should.equal('John Doe'); + it('should handle empty credentials array', function() { + const matches = matchCredentials({ + credentials: [], + queryByExample: {example: {type: 'SomeType'}} + }); + + matches.should.have.length(0); + }); }); - it('should match credentials by nested object fields', async function() { - const query = { - example: { - credentialSubject: { - degree: { - type: 'BachelorDegree' + describe('Semantic Features Tests', function() { + describe('Empty Array Wildcard (anyArray)', function() { + const queryByExample = { + example: { + credentialSubject: { + allergies: [] // Empty array - any array + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match Carol Davis (has allergies: []) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Carol Davis'); + }); + + it('should match credentials with populated arrays', function() { + const queryByExample = { + example: { + credentialSubject: { + skills: [] // Should match any array } } - } - }; + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(1); - matches[0].credentialSubject.degree.type.should.equal('BachelorDegree'); + // Should match Bob Wilson (has skills array) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Bob Wilson'); + }); }); - it('should match credentials by array type field', async function() { - const query = { - example: { - type: 'UniversityDegreeCredential' - } - }; + describe('Empty Object Wildcar (anyValue)', function() { + it('should match any value when example has empty object', function() { + const queryByExample = { + example: { + credentialSubject: { + continuingEducation: {} // Empty object - any value + } + } + }; - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(1); - matches[0].type.should.include('UniversityDegreeCredential'); + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match Eve Martinez (has continuingEducation: {}) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Eve Martinez'); + }); + + it('should match populated objects with empty object wildcard', + function() { + const queryByExample = { + example: { + credentialSubject: { + degree: {} // Should match any degree object + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match John Doe (has degree object) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + }); }); - it('should return empty array when no matches found', async function() { - const query = { - example: { - credentialSubject: { - degree: { - type: 'MastersDegree' // doesn't exist in test data + describe('Null Semantic (mustBeNull)', function() { + + it('should match only when field is null', function() { + const queryByExample = { + example: { + credentialSubject: { + restrictions: null // Must be null + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match Jane Smith (has restrictions: null) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Jane Smith'); + }); + + it('should match multiple null fields', function() { + const queryByExample = { + example: { + credentialSubject: { + medications: null, + disciplinaryActions: null + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match 0 credentials since no credential has + // BOTH fields as null + matches.should.have.length(0); + }); + + it('should match individual null fields correctly', function() { + // Test medications: null + const medicationsQuery = { + example: { + credentialSubject: { + medications: null + } + } + }; + + const medicationsMatches = matchCredentials({ + credentials: mockCredentials, + queryByExample: medicationsQuery + }); + + medicationsMatches.should.have.length(1); + medicationsMatches[0].credentialSubject.name. + should.equal('Carol Davis'); + + // Test disciplinaryActions: null + const disciplinaryQuery = { + example: { + credentialSubject: { + disciplinaryActions: null + } + } + }; + + const disciplinaryMatches = matchCredentials({ + credentials: mockCredentials, + queryByExample: disciplinaryQuery + }); + + disciplinaryMatches.should.have.length(1); + disciplinaryMatches[0].credentialSubject.name. + should.equal('Eve Martinez'); + }); + + it('should match when field is missing', function() { + // use a field that actually exists as null + const queryByExample = { + example: { + credentialSubject: { + medications: null + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Carol Davis'); + }); + }); + + describe('Overlay Matching', function() { + + it('should match when credential has extra fields', function() { + const queryByExample = { + example: { + credentialSubject: { + degree: { + type: 'BachelorDegree' // Only looking for this field + } + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.degree.name + .should.equal('Bachelor of Science'); + matches[0].credentialSubject.degree.major + .should.equal('Computer Science'); + }); + + it('should match nested objects with extra properties', function() { + const queryByExample = { + example: { + credentialSubject: { + alumniOf: { + name: 'University of Example' + // Doesn't specify 'location' or 'accredeitation' + // but credential has them + } + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.alumniOf.location + .should.equal('City, State'); + matches[0].credentialSubject.alumniOf.accreditation.should + .deep.equal(['ABET', 'Regional']); + }); + }); + + describe('Array Matching', function() { + + it('should match single value against array', function() { + const queryByExample = { + example: { + type: 'UniversityDegreeCredential' // Single value + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + // Should match credential with type array containing this value + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + }); + + it('should match array element', function() { + const queryByExample = { + example: { + credentialSubject: { + licenseClass: 'B' // Should match element in ['A', 'B', 'C'] + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Jane Smith'); + }); + + it('should match arrays with common elements', function() { + const queryByExample = { + example: { + credentialSubject: { + skills: ['JavaScript', 'Rust'] // Has JavaScript in common + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Bob Wilson'); + }); + + it('should match array elements in complex structures', function() { + const queryByExample = { + example: { + credentialSubject: { + endorsements: 'Motorcycle' + // Should match element in endorsements array + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Jane Smith'); + }); + }); + + describe('Complex Nested Structures', function() { + + it('should handle deep nesting with multiple levels', function() { + const queryByExample = { + example: { + credentialSubject: { + vaccinations: [{ + name: 'COVID-19' + }] + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Carol Davis'); + }); + + it('should handle multiple field matching (AND logic)', function() { + const queryByExample = { + example: { + type: 'EmployeeCredential', + credentialSubject: { + department: 'Engineering', + skills: 'Python' + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Bob Wilson'); + }); + + it('should handle complex nested object matching', function() { + const queryByExample = { + example: { + credentialSubject: { + address: { + state: 'CA', + city: 'Anytown', + } } } - } - }; + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(0); + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Jane Smith'); + }); }); - it('should return all credentials when no example provided', - async function() { - const query = {}; - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(2); + describe('Error Handling and Edge Cases', function() { + + it('should handle structure mismatch gracefully', function() { + const queryByExample = { + example: { + credentialSubject: { + nonExistentField: { + deepNesting: 'value' + } + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(0); + }); + + it('should handle invalid credentials gracefully', function() { + const invalidCredentials = [ + null, + undefined, + 'string', + 123, + [] + ]; + + const queryByExample = { + example: { + type: 'SomeType' + } + }; + + const matches = matchCredentials({ + credentials: invalidCredentials, + queryByExample + }); + + matches.should.have.length(0); }); - it('should match multiple fields (AND logic)', async function() { - const query = { - example: { + it('should handle complex pointer scenarios', function() { + const queryByExample = { + example: { + credentialSubject: { + manager: { + name: 'Alice Johnson' + } + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Bob Wilson'); + }); + }); + + describe('String Normalization and Type Coercion', function() { + + it('should handle string trimming', function() { + const queryByExample = { + example: { + credentialSubject: { + name: 'Whitespace Person' // No extra spaces + } + } + }; + + const matches = matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + matches.should.have.length(1); + }); + + it('should handle string/number coercion', function() { + const queryByExample = { + example: { + credentialSubject: { + age: 25 // Number + } + } + }; + + const matches = matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + // Should match the credential with age: '25' (string) + matches.should.have.length(1); + }); + + it('should handle reverse number/string coercion', function() { + const queryByExample = { + example: { + credentialSubject: { + yearOfBirth: '1998' // String + } + } + }; + + const matches = matchCredentials({ + credentials: edgeCaseCredentials, + queryByExample + }); + + // Should match the credential with yearOfBirth: 1998 (number) + matches.should.have.length(1); + }); + }); + + describe('Real-world Scenarios', function() { + + it('should handle medical record queries', function() { + const queryByExample = { + example: { + type: 'MedicalCredential', + credentialSubject: { + bloodType: 'O+' + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Carol Davis'); + }); + + it('should handle professional license queries', function() { + const queryByExample = { + example: { + credentialSubject: { + licenseType: 'Nursing', + status: 'Active' + } + } + }; + + const matches = matchCredentials({ + credentials: mockCredentials, + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Eve Martinez'); + }); + }); + }); + + describe('convertExamplesToPointers()', function() { + + describe('Basic Functionality', function() { + it('should convert simple example to pointers', function() { + + const example = { type: 'UniversityDegreeCredential', + credentialSubject: { + name: 'John Doe' + } + }; + + const pointers = convertExampleToPointers({example}); + + pointers.should.be.an('array'); + pointers.length.should.be.greaterThan(0); + + // Check the expected pointers + const pointerStrings = pointers.map(p => p.pointer); + pointerStrings.should.include('/type'); + pointerStrings.should.include('/credentialSubject/name'); + }); + + it('should include match types for each pointer', function() { + const example = { + type: 'TestType', + nullField: null, + emptyArray: [], + emptyObject: {}, + normalField: 'value' + }; + + const pointers = convertExampleToPointers({example}); + + pointers.forEach(pointer => { + pointer.should.have.property('pointer'); + pointer.should.have.property('expectedValue'); + pointer.should.have.property('matchType'); + + // Check match types are correct + if(pointer.expectedValue === null) { + pointer.matchType.should.equal('mustBeNull'); + } else if(Array.isArray(pointer.expectedValue) && + pointer.expectedValue.length === 0) { + pointer.matchType.should.equal('anyArray'); + } else if(typeof pointer.expectedValue === 'object' && + Object.keys(pointer.expectedValue).length === 0) { + pointer.matchType.should.equal('anyValue'); + } else { + pointer.matchType.should.equal('exactMatch'); + } + }); + }); + }); + + describe('Context Handling', function() { + it('should include @context by default', function() { + const example = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: 'TestType' + }; + + const pointers = convertExampleToPointers({example}); + + const pointerStrings = pointers.map(p => p.pointer); + pointerStrings.should.include('/@context/0'); + }); + + it('should exclude @context when includeContext=false', function() { + const example = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: 'TestType' + }; + + const pointers = convertExampleToPointers({ + example, + options: {includeContext: false} + }); + + const pointerStrings = pointers.map(p => p.pointer); + pointerStrings.should.not.include('/@context/0'); + pointerStrings.should.include('/type'); + }); + }); + + describe('Edge Cases', function() { + it('should handle empty example', function() { + const pointers = convertExampleToPointers({example: {}}); + pointers.should.have.length(0); + }); + + it('should handle null example', function() { + const pointers = convertExampleToPointers({example: null}); + pointers.should.have.length(0); + }); + + it('should handle complex nested structures', function() { + const example = { credentialSubject: { degree: { - name: 'Bachelor of Science' + type: 'BachelorDegree', + institution: { + name: 'University', + location: 'City' + } } } - } - }; + }; + + const pointers = convertExampleToPointers({example}); - const matches = matchCredentials(mockCredentials, query); - matches.should.have.length(1); - matches[0].credentialSubject.name.should.equal('John Doe'); - // Also verify the fields queried for are present - matches[0].type.should.include('UniversityDegreeCredential'); - matches[0].credentialSubject.degree.name - .should.equal('Bachelor of Science'); + // Should get deepest pointers + const pointerStrings = pointers.map(p => p.pointer); + pointerStrings.should.include('/credentialSubject/degree/type'); + pointerStrings.should + .include('/credentialSubject/degree/institution/name'); + pointerStrings.should + .include('/credentialSubject/degree/institution/location'); + }); }); - it('should verify batch processing integration', async function() { - // Test module can handle the mock data structure used in presentations.js - const mockMatches = [ - { - record: { - content: { - type: ['VerifiableCredential', 'UniversityDegreeCredential'], - credentialSubject: {degree: {type: 'BachelorDegree'}} + describe('Integration with presentations.match()', function() { + + it('should work with credential store structure', function() { + // Simulate data structure from presentations.js + const mockCredentialRecords = [ + { + record: { + content: mockCredentials[0], // University degree + meta: {id: 'cred-1'} } - } - }, - { - record: { - content: { - type: ['VerifiableCredential', 'DriverLicense'], - credentialSubject: {name: 'Test'} + }, + { + record: { + content: mockCredentials[1], // Driver license + meta: {id: 'cred-2'} } } - } - ]; + ]; - // Simulate what presentations.js does - const allContents = mockMatches.map(match => match.record.content); - const credentialQuery = { - example: { - type: 'UniversityDegreeCredential' - } - }; + // Extract credentials for matching (like presentations.js does) + const credentials = mockCredentialRecords + .map(item => item.record.content); - const matchingContents = matchCredentials(allContents, credentialQuery); + const queryByExample = { + example: { + type: 'UniversityDegreeCredential' + } + }; + + const matches = matchCredentials({credentials, queryByExample}); - // Should find 1 matching credential - matchingContents.should.have.length(1); - matchingContents[0].type.should.include('UniversityDegreeCredential'); + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('John Doe'); + }); + + it('should work with original mockCredential', function() { + // Test backward compatibility with existing mockCredential + const queryByExample = { + example: { + credentialSubject: { + degree: { + type: 'BachelorDegree' + } + } + } + }; + + const matches = matchCredentials({ + credentials: [mockCredential], + queryByExample + }); + + matches.should.have.length(1); + matches[0].credentialSubject.degree.name + .should.equal('Bachelor of Science and Arts'); + }); }); }); }); diff --git a/test/web/mock-data.js b/test/web/mock-data.js index 312072d..bd06045 100644 --- a/test/web/mock-data.js +++ b/test/web/mock-data.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved. */ export const mockCredential = { '@context': [ @@ -42,3 +42,201 @@ export const mockCredential = { proofValue: 'zqvrFELnqNYWBEsqkHPhqxXuQaNf3dpsQ3s6dLgkS1jAtAwXfwxf2TirW4kyPAUHNU3TXbS7JT38aF4jtnXGwiBT' } }; + +// Enhanced test credentials for comprehensive Query By Example testing +export const mockCredentials = [ + // University Degree Credential - complex nested structure + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2' + ], + id: 'http://example.edu/credentials/degree-001', + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + credentialSubject: { + id: 'did:example:123', + name: 'John Doe', + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Science', + major: 'Computer Science', + gpa: 3.8 + }, + alumniOf: { + name: 'University of Example', + location: 'City, State', + accreditation: ['ABET', 'Regional'] + }, + graduationDate: '2023-05-15T00:00:00Z' + }, + issuer: { + id: 'did:example:university', + name: 'University of Example' + }, + validFrom: '2023-01-01T00:00:00Z' + }, + + // Driver License - array fields and null values + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2' + ], + id: 'http://example.dmv/licenses/dl-456', + type: ['VerifiableCredential', 'DriverLicense'], + credentialSubject: { + id: 'did:example:456', + name: 'Jane Smith', + licenseNumber: 'DL123456789', + licenseClass: ['A', 'B', 'C'], // Array for testing + restrictions: null, // Null for testing null semantics + endorsements: ['Motorcycle', 'Commercial'], + address: { + street: '123 Main St', + city: 'Anytown', + state: 'CA', + postalCode: '90210' + } + }, + issuer: { + id: 'did:example:dmv', + name: 'Department of Motor Vehicles' + }, + validFrom: '2022-06-01T00:00:00Z', + validUntil: '2027-06-01T00:00:00Z' + }, + + // Employee Credential - skills array and department info + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2' + ], + id: 'http://example.company/employees/emp-789', + type: ['VerifiableCredential', 'EmployeeCredential'], + credentialSubject: { + id: 'did:example:789', + name: 'Bob Wilson', + employeeId: 'EMP-789', + department: 'Engineering', + position: 'Senior Developer', + skills: ['JavaScript', 'Python', 'Go', 'Docker'], // Array for testing + clearanceLevel: 'Secret', + startDate: '2020-03-01T00:00:00Z', + manager: { + name: 'Alice Johnson', + id: 'did:example:manager-001' + } + }, + issuer: { + id: 'did:example:company', + name: 'Example Corporation' + }, + validFrom: '2020-03-01T00:00:00Z' + }, + + // Medical Credential - testing various data types + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2' + ], + id: 'http://example.hospital/records/med-321', + type: ['VerifiableCredential', 'MedicalCredential'], + credentialSubject: { + id: 'did:example:321', + name: 'Carol Davis', + bloodType: 'O+', + allergies: [], // Empty array for wildcard testing + medications: null, // Null for testing + vaccinations: [ + { + name: 'COVID-19', + date: '2023-01-15T00:00:00Z', + lot: 'ABC123' + }, + { + name: 'Influenza', + date: '2022-10-01T00:00:00Z', + lot: 'FLU456' + } + ], + emergencyContact: { + name: 'David Davis', + relationship: 'Spouse', + phone: '555-0123' + } + }, + issuer: { + id: 'did:example:hospital', + name: 'Example Hospital' + }, + validFrom: '2023-02-01T00:00:00Z' + }, + + // Professional License - minimal structure for edge case testing + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2' + ], + id: 'http://example.board/licenses/prof-555', + type: ['VerifiableCredential', 'ProfessionalLicense'], + credentialSubject: { + id: 'did:example:555', + name: 'Eve Martinez', + licenseType: 'Nursing', + licenseNumber: 'RN987654', + status: 'Active', + specializations: ['ICU', 'Emergency'], // Array + disciplinaryActions: null, // Null testing + continuingEducation: {} // Empty object for wildcard testing + }, + issuer: { + id: 'did:example:nursing-board', + name: 'State Nursing Board' + }, + validFrom: '2021-01-01T00:00:00Z' + } +]; + +// Test credentials for specific edge cases +export const edgeCaseCredentials = [ + // Credential with missing fields (for null testing) + { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:minimal', + name: 'Minimal Person' + // Intentionally missing many fields + }, + issuer: { + id: 'did:example:issuer' + } + }, + + // Credential with string numbers (for type coercion testing) + { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential', 'AgeCredential'], + credentialSubject: { + id: 'did:example:age-test', + name: 'Age Test Person', + age: '25', // String number + yearOfBirth: 1998 // Actual number + }, + issuer: { + id: 'did:example:issuer' + } + }, + + // Credential with whitespace issues (for string normalization testing) + { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + credentialSubject: { + id: 'did:example:whitespace', + name: ' Whitespace Person ', // Extra spaces + title: '\tSenior Engineer\n' // Tabs and newlines + }, + issuer: { + id: 'did:example:issuer' + } + } +];