diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst
index a50875a0bd..7be9bd3246 100644
--- a/docs/pages/deployment/policy.rst
+++ b/docs/pages/deployment/policy.rst
@@ -132,6 +132,44 @@ You can define the following field in the input descriptor constraint, to have t
Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned.
+Presentation definition validation
+**********************************
+
+The node validates presentation definitions when loading policy files, and refuses to start on a definition
+that would fail or misbehave on every request. All problems are reported at once. The checks are:
+
+- **Duplicate ids.** An input descriptor id may be used once per definition, and a field id once per
+ input descriptor. The same field id on fields of *different* descriptors is allowed and meaningful:
+ the chosen credentials must agree on that field's value.
+- **Same-id consistency.** Filters on fields that share an id must allow at least one common value.
+ For example, a ``const`` on one descriptor that is not in another descriptor's ``enum`` for the same
+ id can never be satisfied and fails validation. Two ``pattern`` filters without any ``const`` or
+ ``enum`` are not checked (undecidable); such a conflict surfaces at request time instead.
+- **Filters that can never match.** A ``const`` combined with a non-string ``type``, or an empty
+ ``enum``, rejects every credential. A ``pattern`` that does not compile, or that has more than one
+ capture group, errors on every evaluation.
+- **Ignored constraints.** Keywords the matcher does not evaluate are rejected instead of silently
+ weakening the filter: ``const`` or ``pattern`` next to ``enum`` (``enum`` takes precedence),
+ ``pattern`` on a non-string ``type``, and unsupported JSON Schema keywords such as ``minimum``.
+- **Submission requirements.** Every group referenced by an input descriptor must be covered by a
+ submission requirement, rules must be ``all`` or ``pick``, exactly one of ``from``/``from_nested``
+ must be set, and bounds must be possible (``min`` not above ``max``, no negative values).
+
+A failing definition is reported with every conflict, for example::
+
+ presentation definition 'example-care-pd' is invalid: active: const "true" can never match: it
+ requires type "string", declared type is "boolean"; org_ura: conflicting filter types: string vs number
+
+Guidance for policy authors:
+
+- Issue claims whose values must be filtered or selected **as strings**. The matcher can require a
+ value to be a number (``"type": "number"``), but cannot constrain a number's value: ``const``,
+ ``enum`` and ``pattern`` compare as strings, and numeric JSON Schema keywords are not evaluated.
+- Give every claim used for logging, authorization, or credential selection a distinct field ``id``;
+ only declared field ids are returned by token introspection and usable in ``credential_selection``.
+- When reusing a field ``id`` across input descriptors, make sure the filters agree: the id binds a
+ single value across the chosen credentials.
+
Two-VP flow and cross-VP binding (experimental)
***********************************************
diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go
index f204463f13..631ce28ced 100644
--- a/vcr/pe/presentation_definition.go
+++ b/vcr/pe/presentation_definition.go
@@ -553,11 +553,17 @@ func matchFilter(filter Filter, value interface{}) (bool, interface{}, error) {
}
if filter.Pattern != nil && filter.Type == "string" {
+ str, isString := value.(string)
+ if !isString {
+ // arrays whose elements all failed the filter fall through the type switch;
+ // a pattern can only accept strings
+ return false, nil, nil
+ }
re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript)
if err != nil {
return false, nil, err
}
- match, err := re.FindStringMatch(value.(string))
+ match, err := re.FindStringMatch(str)
if err != nil {
return false, nil, err
}
diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go
index 0d8e19068d..7a3a5b63cc 100644
--- a/vcr/pe/presentation_definition_test.go
+++ b/vcr/pe/presentation_definition_test.go
@@ -764,6 +764,37 @@ func Test_matchField(t *testing.T) {
}
func Test_matchFilter(t *testing.T) {
+ t.Run("array values with a string pattern filter do not panic", func(t *testing.T) {
+ // an array whose elements all fail the filter falls through the type switch; the
+ // pattern check must not assume the value is a string
+ pattern := "^Nuts"
+ filter := Filter{Type: "string", Pattern: &pattern}
+
+ for _, value := range [][]interface{}{
+ {"SomethingElse"},
+ {"SomethingElse", "AlsoNoMatch"},
+ {42.0, true},
+ {},
+ } {
+ match, matched, err := matchFilter(filter, value)
+
+ require.NoError(t, err)
+ assert.False(t, match)
+ assert.Nil(t, matched)
+ }
+ })
+
+ t.Run("an array element matching the pattern still returns the match", func(t *testing.T) {
+ pattern := "^Nuts"
+ filter := Filter{Type: "string", Pattern: &pattern}
+
+ match, value, err := matchFilter(filter, []interface{}{"SomethingElse", "NutsOrganizationCredential"})
+
+ require.NoError(t, err)
+ assert.True(t, match)
+ assert.Equal(t, []interface{}{"SomethingElse", "NutsOrganizationCredential"}, value)
+ })
+
// values for pointer fields
stringValue := "test"
boolValue := true
diff --git a/vcr/pe/types.go b/vcr/pe/types.go
index 56253ded78..86e2e585ab 100644
--- a/vcr/pe/types.go
+++ b/vcr/pe/types.go
@@ -19,7 +19,11 @@
// Package pe stands for Presentation Exchange which includes Presentation Definition and Presentation Submission
package pe
-import "errors"
+import (
+ "encoding/json"
+ "errors"
+ "sort"
+)
var ErrNoCredentials = errors.New("missing credentials")
var ErrMultipleCredentials = errors.New("multiple matching credentials")
@@ -130,6 +134,38 @@ type Filter struct {
Enum []string `json:"enum,omitempty"`
// Pattern is a pattern to match according to ECMA-262, section 21.2.1
Pattern *string `json:"pattern,omitempty"`
+ // unsupported records JSON Schema keywords present in the filter JSON that this
+ // implementation does not evaluate. Parsing stays lenient (remote presentation definitions
+ // keep working); Validate reports them, because a dropped keyword silently weakens the
+ // filter below what its author declared.
+ unsupported []string
+}
+
+// UnmarshalJSON parses the filter and records unsupported keywords instead of silently dropping
+// them.
+func (f *Filter) UnmarshalJSON(data []byte) error {
+ type alias Filter
+ var parsed alias
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ return err
+ }
+ *f = Filter(parsed)
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+ for keyword := range raw {
+ switch keyword {
+ case "type", "const", "enum", "pattern":
+ // annotation keywords carry no constraint, so dropping them weakens nothing
+ case "description", "title", "$comment", "$schema", "$id", "examples", "default",
+ "readOnly", "writeOnly", "deprecated":
+ default:
+ f.unsupported = append(f.unsupported, keyword)
+ }
+ }
+ sort.Strings(f.unsupported)
+ return nil
}
// SubmissionRequirement
diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go
new file mode 100644
index 0000000000..3344d5cc2b
--- /dev/null
+++ b/vcr/pe/validate.go
@@ -0,0 +1,602 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package pe
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/dlclark/regexp2"
+)
+
+// ConflictKind classifies a ValidationConflict.
+type ConflictKind string
+
+const (
+ // ConflictDuplicate is an id declared more than once where it must be unique.
+ ConflictDuplicate ConflictKind = "duplicate"
+ // ConflictType is a field id whose filters declare different value types across descriptors.
+ ConflictType ConflictKind = "type"
+ // ConflictUnsatisfiable is a filter, or a combination of same-id filters, that no value can
+ // ever satisfy.
+ ConflictUnsatisfiable ConflictKind = "unsatisfiable"
+ // ConflictInvalidPattern is a pattern that fails to compile or errors on every match.
+ ConflictInvalidPattern ConflictKind = "invalid_pattern"
+ // ConflictIgnoredConstraint is a declared constraint the matcher silently does not enforce,
+ // so the filter is weaker than the author intended.
+ ConflictIgnoredConstraint ConflictKind = "ignored_constraint"
+ // ConflictSubmissionRequirement is a submission requirement problem that fails every request.
+ ConflictSubmissionRequirement ConflictKind = "submission_requirement"
+)
+
+// ValidationConflict is one problem found by Validate. Subject names what the conflict is
+// about: a field id for filter and same-id problems, an input descriptor id for duplicates and
+// id-less fields, or a group name / submission requirement position for structural problems.
+type ValidationConflict struct {
+ Subject string
+ Kind ConflictKind
+ Detail string
+}
+
+// PDValidationError is returned by Validate; it aggregates every conflict in the presentation
+// definition, sorted, so the author sees all problems at once.
+type PDValidationError struct {
+ PDID string
+ Conflicts []ValidationConflict
+}
+
+// Error renders every conflict in one line, for logs and startup output.
+func (e *PDValidationError) Error() string {
+ lines := make([]string, len(e.Conflicts))
+ for i, conflict := range e.Conflicts {
+ lines[i] = conflict.Subject + ": " + conflict.Detail
+ }
+ return fmt.Sprintf("presentation definition '%s' is invalid: %s", e.PDID, strings.Join(lines, "; "))
+}
+
+// Validate checks a presentation definition for problems that make it misbehave deterministically
+// at request time: duplicate ids, same-id filters no value can satisfy at once, filters that can
+// never match, silently ignored constraints, and submission requirement mistakes. It is a
+// semantic pass on the parsed definition, layered after JSON-schema validation (v2.Validate),
+// which cannot express these cross-field rules. It returns a PDValidationError aggregating every
+// conflict, or nil.
+//
+// # Filter semantics
+//
+// The checks follow the matcher's actual filter semantics (matchFilter), which implement a
+// subset of JSON Schema with these properties:
+//
+// - enum shadows every other keyword: when enum is set, type, const and pattern are not
+// evaluated, and the enum values compare as strings.
+// - const compares as a string and therefore requires type "string"; combined with any other
+// type the filter can never match.
+// - pattern applies only when type is "string"; on any other type it is not evaluated.
+// - numeric values can be required to be numbers (type "number"), but their value cannot be
+// constrained: const, enum and pattern are string-only, and the numeric JSON Schema keywords
+// (minimum, maximum, multipleOf) are not evaluated. Issue claims as strings when their
+// values must be filtered or selected.
+// - any other JSON Schema keyword is parsed but not evaluated; Validate reports it as an
+// ignored constraint, because the filter matches more than its author declared.
+//
+// A field id used on fields of several descriptors binds one value across the chosen
+// credentials, so the filters carrying the id are checked for a common acceptable value. The
+// only case deferred to request time is two or more patterns with no const or enum anywhere:
+// pattern intersection is undecidable in general.
+func Validate(pd PresentationDefinition) error {
+ var conflicts []ValidationConflict
+ conflicts = append(conflicts, duplicateConflicts(pd)...)
+ conflicts = append(conflicts, filterSanityConflicts(pd)...)
+ conflicts = append(conflicts, sameIDConflicts(pd)...)
+ conflicts = append(conflicts, submissionRequirementConflicts(pd)...)
+ if len(conflicts) == 0 {
+ return nil
+ }
+ sort.Slice(conflicts, func(i, j int) bool {
+ a, b := conflicts[i], conflicts[j]
+ if a.Subject != b.Subject {
+ return a.Subject < b.Subject
+ }
+ if a.Kind != b.Kind {
+ return a.Kind < b.Kind
+ }
+ return a.Detail < b.Detail
+ })
+ return &PDValidationError{PDID: pd.Id, Conflicts: conflicts}
+}
+
+// duplicateConflicts reports input descriptor ids used more than once, and field ids declared
+// more than once within a single constraints object. The same field id on different descriptors
+// is not a duplicate: that is the binding mechanism.
+func duplicateConflicts(pd PresentationDefinition) []ValidationConflict {
+ var conflicts []ValidationConflict
+ descriptorSeen := make(map[string]bool)
+ descriptorReported := make(map[string]bool)
+ for _, descriptor := range pd.InputDescriptors {
+ if descriptorSeen[descriptor.Id] && !descriptorReported[descriptor.Id] {
+ descriptorReported[descriptor.Id] = true
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: descriptor.Id,
+ Kind: ConflictDuplicate,
+ Detail: fmt.Sprintf("input descriptor id '%s' is declared more than once", descriptor.Id),
+ })
+ }
+ descriptorSeen[descriptor.Id] = true
+ if descriptor.Constraints == nil {
+ continue
+ }
+ fieldSeen := make(map[string]bool)
+ fieldReported := make(map[string]bool)
+ for _, field := range descriptor.Constraints.Fields {
+ if field.Id == nil {
+ continue
+ }
+ if fieldSeen[*field.Id] && !fieldReported[*field.Id] {
+ fieldReported[*field.Id] = true
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: *field.Id,
+ Kind: ConflictDuplicate,
+ Detail: fmt.Sprintf("field id '%s' is declared more than once in input descriptor '%s'", *field.Id, descriptor.Id),
+ })
+ }
+ fieldSeen[*field.Id] = true
+ }
+ }
+ return conflicts
+}
+
+// filterSanityConflicts checks every filter on its own, following the matcher's actual semantics
+// (see matchFilter): filters that can never match anything, patterns that error, and declared
+// constraints the matcher silently ignores (leaving the filter weaker than its author intended).
+// A conflict is reported under the field id, or under the descriptor id for id-less fields.
+func filterSanityConflicts(pd PresentationDefinition) []ValidationConflict {
+ var conflicts []ValidationConflict
+ for _, descriptor := range pd.InputDescriptors {
+ if descriptor.Constraints == nil {
+ continue
+ }
+ for _, field := range descriptor.Constraints.Fields {
+ if field.Filter == nil {
+ continue
+ }
+ subject := descriptor.Id
+ if field.Id != nil {
+ subject = *field.Id
+ }
+ for _, conflict := range singleFilterConflicts(*field.Filter) {
+ conflict.Subject = subject
+ conflicts = append(conflicts, conflict)
+ }
+ }
+ }
+ return conflicts
+}
+
+// singleFilterConflicts derives the problems of one filter; Subject is filled in by the caller.
+func singleFilterConflicts(filter Filter) []ValidationConflict {
+ var conflicts []ValidationConflict
+ if len(filter.unsupported) > 0 {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: fmt.Sprintf("unsupported filter keywords [%s] are not evaluated: the filter is weaker than declared", strings.Join(filter.unsupported, ", ")),
+ })
+ }
+
+ if filter.Enum != nil {
+ // enum shadows type, const and pattern (matchFilter returns from the enum branch)
+ if len(filter.Enum) == 0 {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictUnsatisfiable,
+ Detail: "enum is empty: the filter can never match",
+ })
+ }
+ if filter.Const != nil {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: fmt.Sprintf("const %q is ignored because enum is set", *filter.Const),
+ })
+ }
+ if filter.Pattern != nil {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: "pattern is ignored because enum is set",
+ })
+ }
+ if filter.Type != "" && filter.Type != "string" {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: fmt.Sprintf("type %q is ignored because enum forces string matching", filter.Type),
+ })
+ }
+ return conflicts
+ }
+
+ switch filter.Type {
+ case "", "string", "number", "boolean":
+ default:
+ // matchFilter's type gate recognizes string, number and boolean only; any other value
+ // (e.g. "integer", "object") rejects every scalar and matches array values by accident
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: fmt.Sprintf("type %q is not evaluated: the matcher recognizes \"string\", \"number\" and \"boolean\" only", filter.Type),
+ })
+ }
+
+ if filter.Const != nil && filter.Type != "string" {
+ // the const compares as a string, but the type gate never lets a string value through
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictUnsatisfiable,
+ Detail: fmt.Sprintf("const %q can never match: it requires type \"string\", declared type is %q", *filter.Const, filter.Type),
+ })
+ }
+
+ if filter.Pattern != nil {
+ pattern, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript)
+ switch {
+ case err != nil:
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictInvalidPattern,
+ Detail: fmt.Sprintf("pattern %q does not compile: %s", *filter.Pattern, err),
+ })
+ case len(pattern.GetGroupNumbers()) > 2:
+ // matchFilter returns an error whenever a pattern with more than one capture
+ // group matches a value
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictInvalidPattern,
+ Detail: fmt.Sprintf("pattern %q has more than one capture group: every match errors", *filter.Pattern),
+ })
+ case filter.Type != "string":
+ // the matcher applies patterns to string-typed filters only
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictIgnoredConstraint,
+ Detail: fmt.Sprintf("pattern is ignored because the declared type is %q: patterns apply to strings only", filter.Type),
+ })
+ case filter.Const != nil:
+ if match, _ := pattern.FindStringMatch(*filter.Const); match == nil {
+ conflicts = append(conflicts, ValidationConflict{
+ Kind: ConflictUnsatisfiable,
+ Detail: fmt.Sprintf("const %q does not match the field's own pattern %q", *filter.Const, *filter.Pattern),
+ })
+ }
+ }
+ }
+ return conflicts
+}
+
+// valueSet models the string values a filter accepts, as matchFilter implements them: enum
+// shadows every other keyword; const and pattern require type "string".
+type valueSet struct {
+ // effectiveType is "string" whenever enum or const constrains the value (both compare as
+ // strings at match time), otherwise the declared type; empty means unconstrained.
+ effectiveType string
+ // finite holds the candidate values when enum or const pins them; nil otherwise.
+ finite []string
+ // pattern is the predicate when only a pattern constrains the value.
+ pattern *regexp2.Regexp
+ // dead marks a filter that can never accept any value.
+ dead bool
+}
+
+// filterValueSet derives the accepted-value set of one filter, following the matcher's actual
+// semantics (see matchFilter), not JSON Schema's.
+func filterValueSet(filter *Filter) valueSet {
+ if filter == nil {
+ return valueSet{}
+ }
+ if filter.Enum != nil {
+ // enum shadows type, const and pattern
+ return valueSet{effectiveType: "string", finite: filter.Enum, dead: len(filter.Enum) == 0}
+ }
+ if filter.Const != nil {
+ set := valueSet{effectiveType: "string", finite: []string{*filter.Const}}
+ if filter.Type != "string" {
+ // the const compares as a string, but the type gate rejects string values
+ set.dead = true
+ return set
+ }
+ if filter.Pattern != nil {
+ if re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript); err == nil {
+ if match, _ := re.FindStringMatch(*filter.Const); match == nil {
+ set.dead = true
+ }
+ }
+ }
+ return set
+ }
+ if filter.Pattern != nil && filter.Type == "string" {
+ if re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript); err == nil {
+ return valueSet{effectiveType: "string", pattern: re}
+ }
+ // a non-compiling pattern is reported as invalid_pattern; no predicate here
+ return valueSet{effectiveType: "string"}
+ }
+ return valueSet{effectiveType: filter.Type}
+}
+
+// idOccurrence is one field carrying a shared id, with its derived value set.
+type idOccurrence struct {
+ descriptorID string
+ set valueSet
+}
+
+// sameIDConflicts checks every field id used on two or more fields across descriptors: the value
+// the id binds to must be able to satisfy all of them at once (Policy 7). Types must agree, and
+// the intersection of the accepted-value sets must not be provably empty. Only the
+// pattern-versus-pattern case (no const or enum anywhere) is deferred to request time.
+func sameIDConflicts(pd PresentationDefinition) []ValidationConflict {
+ occurrences := make(map[string][]idOccurrence)
+ var order []string
+ for _, descriptor := range pd.InputDescriptors {
+ if descriptor.Constraints == nil {
+ continue
+ }
+ for _, field := range descriptor.Constraints.Fields {
+ if field.Id == nil {
+ continue
+ }
+ if _, seen := occurrences[*field.Id]; !seen {
+ order = append(order, *field.Id)
+ }
+ occurrences[*field.Id] = append(occurrences[*field.Id], idOccurrence{
+ descriptorID: descriptor.Id,
+ set: filterValueSet(field.Filter),
+ })
+ }
+ }
+
+ var conflicts []ValidationConflict
+ for _, id := range order {
+ fields := occurrences[id]
+ if len(fields) < 2 {
+ continue
+ }
+ // dead filters are reported on their own; they carry no usable value set here
+ var sets []valueSet
+ for _, occurrence := range fields {
+ if !occurrence.set.dead {
+ sets = append(sets, occurrence.set)
+ }
+ }
+
+ // type agreement: every constrained occurrence must agree on the value type
+ types := make(map[string]bool)
+ for _, set := range sets {
+ if set.effectiveType != "" {
+ types[set.effectiveType] = true
+ }
+ }
+ if len(types) > 1 {
+ names := make([]string, 0, len(types))
+ for name := range types {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: id,
+ Kind: ConflictType,
+ Detail: "conflicting filter types: " + strings.Join(names, " vs "),
+ })
+ continue // value sets of different types cannot be intersected meaningfully
+ }
+
+ // value-set intersection, decidable whenever some filter pins a finite candidate set
+ var candidates []string
+ haveFinite := false
+ for _, set := range sets {
+ if set.finite == nil {
+ continue
+ }
+ if !haveFinite {
+ candidates, haveFinite = set.finite, true
+ continue
+ }
+ candidates = intersect(candidates, set.finite)
+ }
+ if !haveFinite {
+ continue // universes always overlap; pattern-versus-pattern is deferred
+ }
+ if len(candidates) == 0 {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: id,
+ Kind: ConflictUnsatisfiable,
+ Detail: "const/enum values across descriptors share no common value",
+ })
+ continue
+ }
+ for _, set := range sets {
+ if set.pattern == nil {
+ continue
+ }
+ candidates = matchingCandidates(candidates, set.pattern)
+ if len(candidates) == 0 {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: id,
+ Kind: ConflictUnsatisfiable,
+ Detail: fmt.Sprintf("no shared const/enum value matches pattern '%s'", set.pattern.String()),
+ })
+ break
+ }
+ }
+ }
+ return conflicts
+}
+
+// submissionRequirementConflicts checks the submission requirements for mistakes that fail every
+// request at runtime: a descriptor group no requirement covers, unknown rules, from/from_nested
+// violations, impossible bounds, and a rule demanding credentials from a group no descriptor
+// carries. Groups are only meaningful when submission requirements are present; without them the
+// matcher ignores groups entirely.
+func submissionRequirementConflicts(pd PresentationDefinition) []ValidationConflict {
+ if len(pd.SubmissionRequirements) == 0 {
+ return nil
+ }
+ var conflicts []ValidationConflict
+
+ covered := make(map[string]bool)
+ for _, requirement := range pd.SubmissionRequirements {
+ for _, group := range requirement.groups() {
+ covered[group] = true
+ }
+ }
+ descriptorGroups := make(map[string]bool)
+ for _, descriptor := range pd.InputDescriptors {
+ for _, group := range descriptor.Group {
+ descriptorGroups[group] = true
+ if !covered[group] {
+ covered[group] = true // report once
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: group,
+ Kind: ConflictSubmissionRequirement,
+ Detail: fmt.Sprintf("group '%s' (input descriptor '%s') is not covered by any submission requirement: every request fails", group, descriptor.Id),
+ })
+ }
+ }
+ }
+
+ var walk func(requirement SubmissionRequirement, name string)
+ walk = func(requirement SubmissionRequirement, name string) {
+ if requirement.Name != "" {
+ name = fmt.Sprintf("%s (%s)", name, requirement.Name)
+ }
+ if requirement.Rule != "all" && requirement.Rule != "pick" {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: name,
+ Kind: ConflictSubmissionRequirement,
+ Detail: fmt.Sprintf("unknown rule %q (must be \"all\" or \"pick\")", requirement.Rule),
+ })
+ }
+ hasFrom := requirement.From != ""
+ hasNested := len(requirement.FromNested) > 0
+ if hasFrom == hasNested {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: name,
+ Kind: ConflictSubmissionRequirement,
+ Detail: "exactly one of 'from' and 'from_nested' must be set",
+ })
+ }
+ for _, bound := range []struct {
+ name string
+ value *int
+ }{{"count", requirement.Count}, {"min", requirement.Min}, {"max", requirement.Max}} {
+ if bound.value != nil && *bound.value < 0 {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: name,
+ Kind: ConflictSubmissionRequirement,
+ Detail: fmt.Sprintf("'%s' must not be negative", bound.name),
+ })
+ }
+ }
+ if requirement.Min != nil && requirement.Max != nil && *requirement.Min > *requirement.Max {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: name,
+ Kind: ConflictSubmissionRequirement,
+ Detail: fmt.Sprintf("'min' (%d) exceeds 'max' (%d)", *requirement.Min, *requirement.Max),
+ })
+ }
+ // a rule demanding at least one credential from a group no descriptor carries fails
+ // every request; "all" over an empty group passes vacuously and is left alone
+ demandsCredentials := (requirement.Count != nil && *requirement.Count > 0) ||
+ (requirement.Min != nil && *requirement.Min > 0)
+ if hasFrom && demandsCredentials && !descriptorGroups[requirement.From] {
+ conflicts = append(conflicts, ValidationConflict{
+ Subject: requirement.From,
+ Kind: ConflictSubmissionRequirement,
+ Detail: fmt.Sprintf("no input descriptor carries group '%s', but the rule demands credentials from it: every request fails", requirement.From),
+ })
+ }
+ for i, nested := range requirement.FromNested {
+ walk(*nested, fmt.Sprintf("%s.from_nested[%d]", name, i))
+ }
+ }
+ for i, requirement := range pd.SubmissionRequirements {
+ walk(*requirement, fmt.Sprintf("submission_requirements[%d]", i))
+ }
+ return conflicts
+}
+
+// intersect returns the values present in both slices, preserving the order of the first.
+func intersect(a, b []string) []string {
+ inB := make(map[string]bool, len(b))
+ for _, value := range b {
+ inB[value] = true
+ }
+ var result []string
+ for _, value := range a {
+ if inB[value] {
+ result = append(result, value)
+ }
+ }
+ return result
+}
+
+// matchingCandidates keeps the candidates the pattern accepts, using the same regex semantics as
+// the matcher (regexp2, ECMAScript).
+func matchingCandidates(candidates []string, pattern *regexp2.Regexp) []string {
+ var result []string
+ for _, candidate := range candidates {
+ if match, err := pattern.FindStringMatch(candidate); err == nil && match != nil {
+ result = append(result, candidate)
+ }
+ }
+ return result
+}
+
+// UnknownSelectionKeysError is returned by ValidateSelectionKeys when the caller supplied
+// credential_selection keys that are not field ids in any of the supplied presentation
+// definitions.
+type UnknownSelectionKeysError struct {
+ // Keys holds every unknown key, sorted.
+ Keys []string
+}
+
+// Error lists every unknown key in one line.
+func (e *UnknownSelectionKeysError) Error() string {
+ return "unknown credential_selection keys: " + strings.Join(e.Keys, ", ")
+}
+
+// ValidateSelectionKeys checks that every credential_selection key is a field id in at least one
+// of the supplied presentation definitions (the union: one PD for a single-VP request, two for a
+// two-VP request). Key names only are validated; values, including empty strings, play no role.
+// It returns an UnknownSelectionKeysError naming every unknown key, or nil.
+func ValidateSelectionKeys(selection map[string]string, pds ...PresentationDefinition) error {
+ known := make(map[string]bool)
+ for _, pd := range pds {
+ for _, descriptor := range pd.InputDescriptors {
+ if descriptor.Constraints == nil {
+ continue
+ }
+ for _, field := range descriptor.Constraints.Fields {
+ if field.Id != nil {
+ known[*field.Id] = true
+ }
+ }
+ }
+ }
+ var unknown []string
+ for key := range selection {
+ if !known[key] {
+ unknown = append(unknown, key)
+ }
+ }
+ if len(unknown) == 0 {
+ return nil
+ }
+ sort.Strings(unknown)
+ return &UnknownSelectionKeysError{Keys: unknown}
+}
diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go
new file mode 100644
index 0000000000..656e98c7ce
--- /dev/null
+++ b/vcr/pe/validate_test.go
@@ -0,0 +1,664 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package pe
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// mustParsePD unmarshals a presentation definition from its JSON wire form. JSON keeps the
+// deeply-nested PDs readable; DisallowUnknownFields turns a misspelled key into a loud error.
+func mustParsePD(t *testing.T, jsonStr string) PresentationDefinition {
+ t.Helper()
+ dec := json.NewDecoder(strings.NewReader(jsonStr))
+ dec.DisallowUnknownFields()
+ var pd PresentationDefinition
+ require.NoError(t, dec.Decode(&pd))
+ return pd
+}
+
+func TestValidateSelectionKeys(t *testing.T) {
+ singlePD := `{
+ "id": "single-pd",
+ "input_descriptors": [{
+ "id": "patient",
+ "constraints": {"fields": [
+ {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]},
+ {"path": ["$.type"], "filter": {"type": "string", "const": "PatientCredential"}}
+ ]}
+ }]
+ }`
+ spPD := `{
+ "id": "sp-pd",
+ "input_descriptors": [{
+ "id": "delegation",
+ "constraints": {"fields": [{"id": "sp_did", "path": ["$.credentialSubject.id"]}]}
+ }]
+ }`
+
+ t.Run("all keys known passes", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234"}, mustParsePD(t, singlePD))
+
+ assert.NoError(t, err)
+ })
+
+ t.Run("unknown key against a single PD is reported", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"favourite_color": "blue"}, mustParsePD(t, singlePD))
+
+ var unknownErr *UnknownSelectionKeysError
+ require.ErrorAs(t, err, &unknownErr)
+ assert.Equal(t, []string{"favourite_color"}, unknownErr.Keys)
+ assert.EqualError(t, err, "unknown credential_selection keys: favourite_color")
+ })
+
+ t.Run("key targeting one side of a two-PD union passes", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234", "sp_did": "did:web:sp"},
+ mustParsePD(t, singlePD), mustParsePD(t, spPD))
+
+ assert.NoError(t, err)
+ })
+
+ t.Run("unknown key is validated against the union, not each PD independently", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"org_urra": "1"},
+ mustParsePD(t, singlePD), mustParsePD(t, spPD))
+
+ var unknownErr *UnknownSelectionKeysError
+ require.ErrorAs(t, err, &unknownErr)
+ assert.Equal(t, []string{"org_urra"}, unknownErr.Keys)
+ })
+
+ t.Run("empty-string values are validated by name only", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"patient_bsn": ""}, mustParsePD(t, singlePD))
+
+ assert.NoError(t, err)
+ })
+
+ t.Run("multiple unknown keys are all listed, sorted", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"zeta": "1", "alpha": "2", "patient_bsn": "3"},
+ mustParsePD(t, singlePD))
+
+ var unknownErr *UnknownSelectionKeysError
+ require.ErrorAs(t, err, &unknownErr)
+ assert.Equal(t, []string{"alpha", "zeta"}, unknownErr.Keys)
+ })
+
+ t.Run("no PDs supplied makes every key unknown", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234"})
+
+ var unknownErr *UnknownSelectionKeysError
+ require.ErrorAs(t, err, &unknownErr)
+ assert.Equal(t, []string{"patient_bsn"}, unknownErr.Keys)
+ })
+
+ t.Run("nil selection passes", func(t *testing.T) {
+ assert.NoError(t, ValidateSelectionKeys(nil, mustParsePD(t, singlePD)))
+ })
+
+ t.Run("id-less fields do not make the empty key known", func(t *testing.T) {
+ err := ValidateSelectionKeys(map[string]string{"": "x"}, mustParsePD(t, singlePD))
+
+ var unknownErr *UnknownSelectionKeysError
+ require.ErrorAs(t, err, &unknownErr)
+ assert.Equal(t, []string{""}, unknownErr.Keys)
+ })
+
+ t.Run("keys are harvested regardless of PD validity", func(t *testing.T) {
+ // a duplicated descriptor id fails Validate, but key validation stays decoupled
+ pd := mustParsePD(t, `{
+ "id": "invalid-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.a"]}]}},
+ {"id": "d1", "constraints": {"fields": [{"path": ["$.b"]}]}}
+ ]
+ }`)
+
+ assert.NoError(t, ValidateSelectionKeys(map[string]string{"patient_bsn": "x"}, pd))
+ })
+}
+
+// twoFieldPD builds a PD with two descriptors, each carrying one field with the given id and
+// filter JSON (empty filter string means no filter). Most same-id consistency cases fit this shape.
+func twoFieldPD(t *testing.T, id string, filterA string, filterB string) PresentationDefinition {
+ t.Helper()
+ field := func(filter string) string {
+ if filter == "" {
+ return `{"id": "` + id + `", "path": ["$.credentialSubject.a"]}`
+ }
+ return `{"id": "` + id + `", "path": ["$.credentialSubject.a"], "filter": ` + filter + `}`
+ }
+ return mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [`+field(filterA)+`]}},
+ {"id": "d2", "constraints": {"fields": [`+field(filterB)+`]}}
+ ]
+ }`)
+}
+
+// conflictKinds maps FieldID to the conflict kinds reported for it.
+func conflictKinds(t *testing.T, err error) map[string][]ConflictKind {
+ t.Helper()
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ kinds := make(map[string][]ConflictKind)
+ for _, conflict := range pdErr.Conflicts {
+ kinds[conflict.Subject] = append(kinds[conflict.Subject], conflict.Kind)
+ }
+ return kinds
+}
+
+func TestValidate_Duplicates(t *testing.T) {
+ t.Run("duplicate field id within one constraints object", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{
+ "id": "d1",
+ "constraints": {"fields": [
+ {"id": "org_ura", "path": ["$.credentialSubject.ura"]},
+ {"id": "org_ura", "path": ["$.credentialSubject.organization.ura"]}
+ ]}
+ }]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictDuplicate}, kinds["org_ura"])
+ })
+
+ t.Run("same id across descriptors is not a duplicate", func(t *testing.T) {
+ assert.NoError(t, Validate(twoFieldPD(t, "org_ura", "", "")))
+ })
+
+ t.Run("duplicate input descriptor ids", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [{"path": ["$.a"]}]}},
+ {"id": "d1", "constraints": {"fields": [{"path": ["$.b"]}]}}
+ ]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictDuplicate}, kinds["d1"])
+ })
+}
+
+func TestValidate_SameIDConsistency(t *testing.T) {
+ t.Run("conflicting filter types", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "org_ura",
+ `{"type": "string", "pattern": "^\\d{8}$"}`,
+ `{"type": "number"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictType}, kinds["org_ura"])
+ assert.ErrorContains(t, err, "number")
+ assert.ErrorContains(t, err, "string")
+ })
+
+ t.Run("enum forces string matching, so enum vs type number conflicts", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "enum": ["a"]}`,
+ `{"type": "number"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictType}, kinds["x"])
+ })
+
+ t.Run("differing consts", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "const": "A"}`,
+ `{"type": "string", "const": "B"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("const not in enum", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "const": "Z"}`,
+ `{"type": "string", "enum": ["A", "B"]}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("disjoint enums", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "enum": ["A", "B"]}`,
+ `{"type": "string", "enum": ["C"]}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("const failing another field's pattern", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "const": "99991123"}`,
+ `{"type": "string", "pattern": "^\\d{9}$"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("enum with no member matching another field's pattern", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "enum": ["GRANTED", "WITHDRAWN"]}`,
+ `{"type": "string", "pattern": "^[a-z]+$"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("allowed: overlapping value sets", func(t *testing.T) {
+ assert.NoError(t, Validate(twoFieldPD(t, "x",
+ `{"type": "string", "const": "granted"}`,
+ `{"type": "string", "enum": ["granted", "withdrawn"]}`)))
+ })
+
+ t.Run("allowed: path-only difference, no filters", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.id"]}]}},
+ {"id": "d2", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}}
+ ]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("allowed: matching type-only filters", func(t *testing.T) {
+ assert.NoError(t, Validate(twoFieldPD(t, "x",
+ `{"type": "string"}`,
+ `{"type": "string"}`)))
+ })
+
+ t.Run("allowed: single occurrence with a filter", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{
+ "id": "d1",
+ "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}]}
+ }]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("deferred: pattern versus pattern", func(t *testing.T) {
+ // two compilable patterns with an empty intersection are not detected at load;
+ // this degrades to a request-time no-match, surfaced by the MatchReport
+ assert.NoError(t, Validate(twoFieldPD(t, "x",
+ `{"type": "string", "pattern": "^a"}`,
+ `{"type": "string", "pattern": "^b"}`)))
+ })
+
+ t.Run("multiple conflicts are aggregated and sorted", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [
+ {"id": "zeta", "path": ["$.a"], "filter": {"type": "string", "const": "A"}},
+ {"id": "alpha", "path": ["$.b"], "filter": {"type": "string"}}
+ ]}},
+ {"id": "d2", "constraints": {"fields": [
+ {"id": "zeta", "path": ["$.a"], "filter": {"type": "string", "const": "B"}},
+ {"id": "alpha", "path": ["$.b"], "filter": {"type": "number"}}
+ ]}}
+ ]
+ }`)
+
+ err := Validate(pd)
+
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ require.Len(t, pdErr.Conflicts, 2)
+ assert.Equal(t, "alpha", pdErr.Conflicts[0].Subject)
+ assert.Equal(t, ConflictType, pdErr.Conflicts[0].Kind)
+ assert.Equal(t, "zeta", pdErr.Conflicts[1].Subject)
+ assert.Equal(t, ConflictUnsatisfiable, pdErr.Conflicts[1].Kind)
+ assert.ErrorContains(t, err, "test-pd")
+ })
+
+ t.Run("three-way intersection: pairwise overlapping but globally disjoint enums", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "B"]}}]}},
+ {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B", "C"]}}]}},
+ {"id": "d3", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "C"]}}]}}
+ ]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("three-way intersection with a surviving value passes", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "B"]}}]}},
+ {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B", "C"]}}]}},
+ {"id": "d3", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B"]}}]}}
+ ]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("a within-descriptor duplicate still participates in same-id consistency", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [
+ {"id": "d1", "constraints": {"fields": [
+ {"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}},
+ {"id": "x", "path": ["$.b"], "filter": {"type": "string", "const": "B"}}
+ ]}},
+ {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}]}}
+ ]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.ElementsMatch(t, []ConflictKind{ConflictDuplicate, ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("a dead filter is excluded from same-id consistency", func(t *testing.T) {
+ // the dead filter earns its own conflict; it must not also produce a bogus type conflict
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "boolean", "const": "true"}`,
+ `{"type": "string", "const": "A"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("the aggregated error string is stable", func(t *testing.T) {
+ err := Validate(twoFieldPD(t, "x",
+ `{"type": "string", "const": "A"}`,
+ `{"type": "string", "const": "B"}`))
+
+ assert.EqualError(t, err, "presentation definition 'test-pd' is invalid: x: const/enum values across descriptors share no common value")
+ })
+
+ t.Run("descriptors without constraints are skipped", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{"id": "d1"}, {"id": "d2"}]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("empty PD is valid", func(t *testing.T) {
+ assert.NoError(t, Validate(mustParsePD(t, `{"id": "empty", "input_descriptors": []}`)))
+ })
+}
+
+// singleFilterPD builds a PD with one descriptor carrying one field with the given filter JSON.
+func singleFilterPD(t *testing.T, filter string) PresentationDefinition {
+ t.Helper()
+ return mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{
+ "id": "d1",
+ "constraints": {"fields": [{"id": "x", "path": ["$.credentialSubject.x"], "filter": `+filter+`}]}
+ }]
+ }`)
+}
+
+func TestValidate_FilterSanity(t *testing.T) {
+ t.Run("const on a non-string type can never match", func(t *testing.T) {
+ // matchFilter compares const as a string; a boolean value never equals it
+ err := Validate(singleFilterPD(t, `{"type": "boolean", "const": "true"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ assert.ErrorContains(t, err, "true")
+ })
+
+ t.Run("empty enum can never match", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "string", "enum": []}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("const failing its own pattern can never match", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "string", "const": "abc", "pattern": "^\\d+$"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"])
+ })
+
+ t.Run("non-compiling pattern is reported", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "string", "pattern": "["}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictInvalidPattern}, kinds["x"])
+ })
+
+ t.Run("pattern with more than one capture group errors on every match", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "string", "pattern": "^(\\d+)-(\\d+)$"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictInvalidPattern}, kinds["x"])
+ })
+
+ t.Run("pattern on a non-string type is silently ignored by the matcher", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "number", "pattern": "^\\d+$"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ })
+
+ t.Run("const alongside enum is silently ignored by the matcher", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"], "const": "Z"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ })
+
+ t.Run("non-string type alongside enum is silently ignored by the matcher", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "number", "enum": ["1", "2"]}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ })
+
+ t.Run("unsupported JSON Schema keywords are reported instead of silently dropped", func(t *testing.T) {
+ err := Validate(singleFilterPD(t, `{"type": "number", "minimum": 18}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ assert.ErrorContains(t, err, "minimum")
+ })
+
+ t.Run("a dead filter on a field without an id is reported under the descriptor id", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{
+ "id": "d1",
+ "constraints": {"fields": [{"path": ["$.credentialSubject.x"], "filter": {"type": "boolean", "const": "true"}}]}
+ }]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["d1"])
+ })
+
+ t.Run("structural: descriptor group not covered by any submission requirement", func(t *testing.T) {
+ // runtime fails every request on this PD with "group is required but not available"
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "all", "from": "org"}],
+ "input_descriptors": [
+ {"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}},
+ {"id": "d2", "group": ["delegation"], "constraints": {"fields": [{"path": ["$.b"]}]}}
+ ]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictSubmissionRequirement}, kinds["delegation"])
+ })
+
+ t.Run("structural: groups without submission requirements are fine", func(t *testing.T) {
+ // with no submission requirements at all, groups are decorative (all-required matching)
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("structural: unknown rule", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "any", "from": "org"}],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+
+ err := Validate(pd)
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ require.Len(t, pdErr.Conflicts, 1)
+ assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind)
+ assert.ErrorContains(t, err, "any")
+ })
+
+ t.Run("structural: from and from_nested are mutually exclusive and required", func(t *testing.T) {
+ both := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "all", "from": "org", "from_nested": [{"rule": "all", "from": "org"}]}],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+ neither := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "all"}],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+
+ for _, pd := range []PresentationDefinition{both, neither} {
+ err := Validate(pd)
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ require.NotEmpty(t, pdErr.Conflicts)
+ assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind)
+ }
+ })
+
+ t.Run("structural: impossible pick bounds", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "pick", "from": "org", "min": 3, "max": 1}],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+
+ err := Validate(pd)
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ require.Len(t, pdErr.Conflicts, 1)
+ assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind)
+ })
+
+ t.Run("structural: pick demanding credentials from a group no descriptor carries", func(t *testing.T) {
+ // apply() sees an empty group and fails every request
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [
+ {"rule": "all", "from": "org"},
+ {"rule": "pick", "from": "ghost", "count": 1}
+ ],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+
+ kinds := conflictKinds(t, Validate(pd))
+ assert.Equal(t, []ConflictKind{ConflictSubmissionRequirement}, kinds["ghost"])
+ })
+
+ t.Run("structural: nested submission requirements are walked", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [{"rule": "pick", "count": 1, "from_nested": [
+ {"rule": "any", "from": "org"}
+ ]}],
+ "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}]
+ }`)
+
+ err := Validate(pd)
+ var pdErr *PDValidationError
+ require.ErrorAs(t, err, &pdErr)
+ require.Len(t, pdErr.Conflicts, 1)
+ assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind)
+ assert.ErrorContains(t, err, "any")
+ })
+
+ t.Run("structural: a valid submission requirement setup passes", func(t *testing.T) {
+ pd := mustParsePD(t, `{
+ "id": "test-pd",
+ "submission_requirements": [
+ {"rule": "all", "from": "org"},
+ {"rule": "pick", "from": "consent", "min": 0, "max": 1}
+ ],
+ "input_descriptors": [
+ {"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}},
+ {"id": "d2", "group": ["consent"], "constraints": {"fields": [{"path": ["$.b"]}]}}
+ ]
+ }`)
+ assert.NoError(t, Validate(pd))
+ })
+
+ t.Run("annotation keywords are not flagged", func(t *testing.T) {
+ assert.NoError(t, Validate(singleFilterPD(t,
+ `{"type": "string", "pattern": "^Nuts", "description": "the credential type", "title": "Type", "examples": ["NutsOrganizationCredential"]}`)))
+ })
+
+ t.Run("unrecognized type values are flagged", func(t *testing.T) {
+ // matchFilter's type gate recognizes string, number and boolean only; "integer" is
+ // valid JSON Schema but rejects every scalar at runtime
+ err := Validate(singleFilterPD(t, `{"type": "integer"}`))
+
+ kinds := conflictKinds(t, err)
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ assert.ErrorContains(t, err, "integer")
+ })
+
+ t.Run("unsupported keywords survive the production parser", func(t *testing.T) {
+ // ParsePresentationDefinition runs the DIF JSON schema first (which allows any draft-07
+ // keyword), then unmarshals; the capture must reach Validate through that path
+ pd, err := ParsePresentationDefinition([]byte(`{
+ "id": "e2e-pd",
+ "input_descriptors": [{
+ "id": "d1",
+ "constraints": {"fields": [{"id": "x", "path": ["$.credentialSubject.age"], "filter": {"type": "number", "minimum": 18}}]}
+ }]
+ }`))
+ require.NoError(t, err)
+
+ kinds := conflictKinds(t, Validate(*pd))
+ assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"])
+ })
+
+ t.Run("allowed: well-formed filters", func(t *testing.T) {
+ assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "const": "A", "pattern": "^[A-Z]$"}`)))
+ assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"]}`)))
+ assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "pattern": "^(\\d+)$"}`)))
+ assert.NoError(t, Validate(singleFilterPD(t, `{"type": "number"}`)))
+ })
+}