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/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..c4c079b 100644 --- a/lib/presentations.js +++ b/lib/presentations.js @@ -9,9 +9,11 @@ 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'; +import {matchCredentials} from './queryByExample.js'; import {profileManager} from './state.js'; import {supportedSuites} from './cryptoSuites.js'; import {v4 as uuid} from 'uuid'; @@ -389,8 +391,25 @@ async function _getMatches({ // match any open badge achievement ID // FIXME: add more generalized matching result.matches = matches - .filter(_matchContextFilter({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({ + credentials: allContents, queryByExample: 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}; @@ -399,7 +418,6 @@ async function _getMatches({ vprQuery: updatedQuery, matches: result.matches }); } - return results; } @@ -459,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 new file mode 100644 index 0000000..5d11fb3 --- /dev/null +++ b/lib/queryByExample.js @@ -0,0 +1,808 @@ +/*! + * 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). + * + * @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_v0(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); + + 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); + + return result; + } catch(e) { + // If pointer doesn't exist in credential, it's not a match + debugLog('Pointer error:', pointer, e.message); + return false; + } + }); + }); +} + +/** + * 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 + * 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; +} + +/** + * 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. + * + * @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_v0(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_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_v0(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 => _valuesMatchExact(item1, item2)) + ); +} + +/** + * NOT IN USE - Version 0 - use _objectsMatchOverlay instead. + * + * 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_v0(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; +} + +// ============================================================================= +// 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 d819d65..abd2340 100644 --- a/test/web/10-api.js +++ b/test/web/10-api.js @@ -299,3 +299,88 @@ 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 new file mode 100644 index 0000000..a2de11a --- /dev/null +++ b/test/web/15-query-by-example.js @@ -0,0 +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, convertExampleToPointers} = queryByExample; + +describe('queryByExample', function() { + + describe('matchCredentials()', function() { + + 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); + }); + + it('should handle empty credentials array', function() { + const matches = matchCredentials({ + credentials: [], + queryByExample: {example: {type: 'SomeType'}} + }); + + matches.should.have.length(0); + }); + }); + + 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 + }); + + // Should match Bob Wilson (has skills array) + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Bob Wilson'); + }); + }); + + 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({ + 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'); + }); + }); + + 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 + }); + + matches.should.have.length(1); + matches[0].credentialSubject.name.should.equal('Jane Smith'); + }); + }); + + 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 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: { + type: 'BachelorDegree', + institution: { + name: 'University', + location: 'City' + } + } + } + }; + + const pointers = convertExampleToPointers({example}); + + // 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'); + }); + }); + + 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: mockCredentials[1], // Driver license + meta: {id: 'cred-2'} + } + } + ]; + + // Extract credentials for matching (like presentations.js does) + const credentials = mockCredentialRecords + .map(item => item.record.content); + + const queryByExample = { + example: { + type: 'UniversityDegreeCredential' + } + }; + + const matches = matchCredentials({credentials, queryByExample}); + + 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' + } + } +];