sd-jwt-0.1.0.0: test/interop/TestCaseRunner.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Helper functions for running interoperability test cases.
--
-- This module provides functions to convert test case formats to the public API format.
module TestCaseRunner
( extractDisclosurePaths
, convertClaimsToObject
, identifySelectivelyDisclosableClaims
, compareClaims
) where
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Vector as V
import Data.Maybe (mapMaybe)
import Data.List (nub)
-- | Convert holder_disclosed_claims (boolean format) to JSON Pointer paths.
--
-- Examples:
-- {"given_name": true} → ["given_name"]
-- {"data_types": [True, False, True]} → ["data_types/0", "data_types/2"]
-- {"nested_array": [[True, False], [False, True]]} → ["nested_array/0/0", "nested_array/1/1"]
extractDisclosurePaths :: Map.Map T.Text Aeson.Value -> [T.Text]
extractDisclosurePaths holderDisclosed = concatMap (\(name, value) -> extractPathsForClaim name value) (Map.toList holderDisclosed)
where
extractPathsForClaim :: T.Text -> Aeson.Value -> [T.Text]
extractPathsForClaim claimName (Aeson.Bool True) = [claimName]
extractPathsForClaim _ (Aeson.Bool False) = []
extractPathsForClaim claimName (Aeson.Array arr) =
concatMap (\(idx, val) -> extractPathsForArrayElement claimName idx val) (zip [0..] (V.toList arr))
extractPathsForClaim claimName (Aeson.Object obj) =
-- For objects, if the value is true-like, include the claim itself
-- Otherwise, recurse into nested structure
if KeyMap.null obj then []
else concatMap (\(k, v) -> extractPathsForClaim (claimName <> "/" <> Key.toText k) v) (KeyMap.toList obj)
extractPathsForClaim _ _ = []
extractPathsForArrayElement :: T.Text -> Int -> Aeson.Value -> [T.Text]
extractPathsForArrayElement claimName idx (Aeson.Bool True) = [claimName <> "/" <> T.pack (show idx)]
extractPathsForArrayElement _ _ (Aeson.Bool False) = []
extractPathsForArrayElement claimName idx (Aeson.Array arr) =
concatMap (\(innerIdx, val) -> extractPathsForArrayElement (claimName <> "/" <> T.pack (show idx)) innerIdx val) (zip [0 :: Int ..] (V.toList arr))
extractPathsForArrayElement claimName idx (Aeson.Object obj) =
-- Nested object in array
concatMap (\(k, v) -> extractPathsForClaim (claimName <> "/" <> T.pack (show idx) <> "/" <> Key.toText k) v) (KeyMap.toList obj)
extractPathsForArrayElement _ _ _ = []
-- | Convert Map Text Value to Aeson.Object.
convertClaimsToObject :: Map.Map T.Text Aeson.Value -> Aeson.Object
convertClaimsToObject claims = KeyMap.fromList $ map (\(k, v) -> (Key.fromText k, v)) (Map.toList claims)
-- | Compare verified claims with expected claims.
--
-- This function does a deep comparison, checking that the expected claims match
-- the verified claims. It handles nested objects and arrays correctly.
-- Only checks that expected claims are present and match - extra claims in verified are ignored.
compareClaims :: Aeson.Object -> Aeson.Object -> Either String ()
compareClaims verified expected =
-- Check that all expected keys are present and match
let expectedKeys = KeyMap.keys expected
missingKeys = filter (\k -> not (KeyMap.member k verified)) expectedKeys
in case missingKeys of
(_:_) -> Left $ "Missing expected keys: " ++ show (map Key.toText missingKeys)
[] ->
-- Check each expected key matches
let mismatches = mapMaybe (\k ->
case (KeyMap.lookup k verified, KeyMap.lookup k expected) of
(Just vVal, Just eVal) ->
if compareValues vVal eVal then Nothing else Just (Key.toText k, vVal, eVal)
_ -> Just (Key.toText k, Aeson.Null, Aeson.Null)
) expectedKeys
in case mismatches of
[] -> Right ()
((keyName, vVal, eVal):_) -> Left $ "Mismatch at key " ++ T.unpack keyName ++ ": expected " ++ show eVal ++ ", got " ++ show vVal
where
compareValues :: Aeson.Value -> Aeson.Value -> Bool
compareValues (Aeson.Object vObj) (Aeson.Object eObj) =
case compareClaims vObj eObj of
Right () -> True
Left _ -> False
compareValues (Aeson.Array vArr) (Aeson.Array eArr) =
-- For arrays, we need to compare element by element
-- But arrays might have different ordering, so we compare lengths and elements
V.length vArr == V.length eArr &&
all (uncurry compareValues) (zip (V.toList vArr) (V.toList eArr))
compareValues v e = v == e
-- | Identify which claims are selectively disclosable.
--
-- Strategy:
-- 1. If holder_disclosed_claims is not empty, use it to identify selectively disclosable claims
-- (claims that appear in holder_disclosed_claims were marked with !sd)
-- 2. If holder_disclosed_claims is empty, infer from comparing user_claims with expect_verified_user_claims
-- (claims that appear in user_claims but not in expect_verified_user_claims were selectively disclosable)
--
-- Examples:
-- {"given_name": true} → ["given_name"]
-- {"nationalities": [False, True]} → ["nationalities/0", "nationalities/1"] (both were !sd, only 1 is disclosed)
identifySelectivelyDisclosableClaims :: Map.Map T.Text Aeson.Value -> Map.Map T.Text Aeson.Value -> Map.Map T.Text Aeson.Value -> [T.Text]
identifySelectivelyDisclosableClaims userClaims holderDisclosed expectedVerified =
if Map.null holderDisclosed
then inferFromComparison userClaims expectedVerified
else extractAllPaths holderDisclosed
where
extractAllPaths :: Map.Map T.Text Aeson.Value -> [T.Text]
extractAllPaths m = concatMap (\(name, value) -> extractPaths name value) (Map.toList m)
extractPaths :: T.Text -> Aeson.Value -> [T.Text]
extractPaths claimName (Aeson.Bool _) = [claimName] -- Both True and False mean !sd was present
extractPaths claimName (Aeson.Array arr) =
concatMap (\(idx, val) -> extractArrayPaths claimName idx val) (zip [0..] (V.toList arr))
extractPaths claimName (Aeson.Object obj) =
concatMap (\(k, v) -> extractPaths (claimName <> "/" <> Key.toText k) v) (KeyMap.toList obj)
extractPaths _ _ = []
extractArrayPaths :: T.Text -> Int -> Aeson.Value -> [T.Text]
extractArrayPaths claimName idx (Aeson.Bool _) = [claimName <> "/" <> T.pack (show idx)]
extractArrayPaths claimName idx (Aeson.Array arr) =
concatMap (\(innerIdx, val) -> extractArrayPaths (claimName <> "/" <> T.pack (show idx)) innerIdx val) (zip [0..] (V.toList arr))
extractArrayPaths claimName idx (Aeson.Object obj) =
concatMap (\(k, v) -> extractPaths (claimName <> "/" <> T.pack (show idx) <> "/" <> Key.toText k) v) (KeyMap.toList obj)
extractArrayPaths _ _ _ = []
-- Infer selectively disclosable claims by comparing user_claims with expect_verified_user_claims
inferFromComparison :: Map.Map T.Text Aeson.Value -> Map.Map T.Text Aeson.Value -> [T.Text]
inferFromComparison user expected =
-- Compare each top-level claim
concatMap (\(name, userVal) ->
case Map.lookup name expected of
Just expectedVal -> inferPathsForValue name userVal expectedVal
Nothing -> [name] -- Claim not in expected, so it's selectively disclosable
) (Map.toList user)
-- Infer selectively disclosable paths by comparing two values
inferPathsForValue :: T.Text -> Aeson.Value -> Aeson.Value -> [T.Text]
inferPathsForValue claimName (Aeson.Array userArr) (Aeson.Array expectedArr) =
-- Compare arrays element by element
let userList = V.toList userArr
expectedList = V.toList expectedArr
userLen = length userList
expectedLen = length expectedList
-- If arrays have different lengths, mark extra indices as selectively disclosable
-- Also mark indices where values differ
selectiveIndices =
-- Indices beyond expected length are selectively disclosable (if user is longer)
(if userLen > expectedLen then [expectedLen .. userLen - 1] else []) ++
-- Indices where values differ (up to expected length)
mapMaybe (\(idx, uVal, eVal) ->
if idx < expectedLen && not (compareValuesForInference uVal eVal)
then Just idx
else Nothing
) (zip3 [0..] userList expectedList)
-- For each selectively disclosable index, recursively check its contents
basePaths = map (\idx -> claimName <> "/" <> T.pack (show idx)) (nub selectiveIndices)
-- Recursively infer paths for selectively disclosable elements
recursivePaths = concatMap (\(idx, uVal) ->
if idx `elem` selectiveIndices
then case (uVal, if idx < expectedLen then Just (expectedList !! idx) else Nothing) of
(Aeson.Array _, Just (Aeson.Array eArr)) ->
-- Recursively check nested array
inferPathsForValue (claimName <> "/" <> T.pack (show idx)) uVal (Aeson.Array eArr)
(Aeson.Object _, Just (Aeson.Object eObj)) ->
-- Recursively check nested object
inferPathsForValue (claimName <> "/" <> T.pack (show idx)) uVal (Aeson.Object eObj)
(Aeson.Object _, Just (Aeson.Array _)) ->
-- Object element was replaced with empty array, recursively mark all its contents as selectively disclosable
extractAllPathsFromValue (claimName <> "/" <> T.pack (show idx)) uVal
(Aeson.Array _, Nothing) ->
-- Array element was removed, recursively mark all its contents as selectively disclosable
extractAllPathsFromValue (claimName <> "/" <> T.pack (show idx)) uVal
(Aeson.Object _, Nothing) ->
-- Object element was removed, recursively mark all its contents as selectively disclosable
extractAllPathsFromValue (claimName <> "/" <> T.pack (show idx)) uVal
_ -> []
else []
) (zip [0..] userList)
in basePaths ++ recursivePaths
inferPathsForValue claimName (Aeson.Object userObj) (Aeson.Object expectedObj) =
-- Compare objects property by property
let userKeys = KeyMap.keys userObj
selectiveKeys = filter (\k ->
case (KeyMap.lookup k userObj, KeyMap.lookup k expectedObj) of
(Just uVal, Just eVal) -> not (compareValuesForInference uVal eVal)
(Just _, Nothing) -> True -- In user but not expected
_ -> False
) userKeys
basePaths = map (\k -> claimName <> "/" <> Key.toText k) selectiveKeys
-- Recursively infer paths for selectively disclosable properties
recursivePaths = concatMap (\k ->
case (KeyMap.lookup k userObj, KeyMap.lookup k expectedObj) of
(Just uVal, Just eVal) ->
if k `elem` selectiveKeys
then inferPathsForValue (claimName <> "/" <> Key.toText k) uVal eVal
else []
(Just uVal, Nothing) ->
-- Property was removed, recursively mark all its contents
extractAllPathsFromValue (claimName <> "/" <> Key.toText k) uVal
_ -> []
) userKeys
in basePaths ++ recursivePaths
inferPathsForValue claimName userVal expectedVal =
-- Primitive values: if different, the claim is selectively disclosable
if userVal == expectedVal then [] else [claimName]
-- Compare values for inference (simplified comparison)
compareValuesForInference :: Aeson.Value -> Aeson.Value -> Bool
compareValuesForInference (Aeson.Array uArr) (Aeson.Array eArr) =
V.length uArr == V.length eArr
compareValuesForInference (Aeson.Object uObj) (Aeson.Object eObj) =
KeyMap.size uObj == KeyMap.size eObj
compareValuesForInference u e = u == e
-- Extract all paths from a value (for when an element is completely removed)
extractAllPathsFromValue :: T.Text -> Aeson.Value -> [T.Text]
extractAllPathsFromValue basePath (Aeson.Array arr) =
-- Mark the array itself and all its elements
basePath : concatMap (\(idx, val) ->
extractAllPathsFromValue (basePath <> "/" <> T.pack (show (idx :: Int))) val
) (zip [0..] (V.toList arr))
extractAllPathsFromValue basePath (Aeson.Object obj) =
-- Mark the object itself and all its properties
basePath : concatMap (\(k, val) ->
extractAllPathsFromValue (basePath <> "/" <> Key.toText k) val
) (KeyMap.toList obj)
extractAllPathsFromValue basePath _ = [basePath] -- Primitive value