diff --git a/README.md b/README.md index 15833e6..bdc310b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ and do not establish a presumption of conformity. - Criteria with `check.type: resolve` request the product identifier themselves (with the Accept header the criterion names) and need `GET /api/v1/validate/`; with `POST` they are skipped. - `expect` checks status and content type first and the header fields + `expect` checks status and content type first (media type without + parameters, case-insensitive; for `application/json` and `+json` types the + body must be a single JSON object) and the header fields (`exists`, `equals`, `contains`, `matches`; field names case-insensitive, several fields of one name combined with commas) only if both hold. `matches` is an ECMA-262 regular expression without flags, searched @@ -37,6 +39,13 @@ and do not establish a presumption of conformity. evaluated independently. A criterion fails if a check with severity error fails; if only checks with `severity: warning` fail, the result is `warning`. +- `applies_if` supports the paths `$.` and + `$.[?search(@, '')]` or `[?match(@, '')]`. The + regular expression of `search()` and `match()` is an I-Regexp (RFC 9485) as + RFC 9535 requires: `match()` needs the entire value, `search()` a substring, + and a pattern that does not conform selects nothing. `matches` of the + condition itself is ECMA-262 as above. A condition dpplint cannot evaluate + skips the criterion with the reason. - Criteria with `check.type: did` send the DIDs of the passport to [didlint](https://didlint.ownyourdata.eu) (DID Core, DID Resolution) and check linked verifiable presentations for the VC Data Model 2.0 context. The diff --git a/app/services/applies_if.rb b/app/services/applies_if.rb index abe7697..40297d1 100644 --- a/app/services/applies_if.rb +++ b/app/services/applies_if.rb @@ -1,13 +1,107 @@ # Evaluates the applies_if conditions of a criterion on the passport JSON. -# Supported paths: "$.attribute" and "$.attribute[?search(@, '')]". +# Each condition is a JSON assertion (CRITERIA-FORMAT.md): a JSONPath `path` +# and one of `exists`, `equals`, `in`, `matches`. +# +# Supported paths (RFC 9535): +# $. +# $.[?search(@, '')] +# $.[?match(@, '')] +# The string literal may be single- or double-quoted with the escapes of +# RFC 9535, 2.3.1.1. The filter selects the elements of an array (or the +# member values of an object) for which the function holds; search() and +# match() evaluate their pattern as I-Regexp (RFC 9485, see IRegexp), are false +# for values that are not strings and, following RFC 9535, false for a pattern +# that does not conform to RFC 9485. +# +# `matches` of the assertion itself is an ECMA-262 regular expression +# (see EcmaRegexp), as for all JSON assertions. class AppliesIf - SIMPLE = /\A\$\.([A-Za-z_][A-Za-z0-9_]*)\z/ - SEARCH = /\A\$\.([A-Za-z_][A-Za-z0-9_]*)\[\?search\(@,\s*'(.*)'\)\]\z/ + MEMBER = /[A-Za-z_\u0080-\u{10FFFF}][A-Za-z0-9_\u0080-\u{10FFFF}]*/ + SIMPLE = /\A\$\.(#{MEMBER})\z/ + FILTER = /\A\$\.(#{MEMBER})\[\s*\?\s*(match|search)\(\s*@\s*,\s*(.*)\s*\)\s*\]\z/m + ESCAPES = { "b" => "\b", "f" => "\f", "n" => "\n", "r" => "\r", "t" => "\t", "/" => "/", "\\" => "\\" }.freeze + + class Unsupported < StandardError; end def self.holds?(conditions, passport) Array(conditions).all? { |c| new(c, passport).holds? } end + # nil if every condition can be evaluated, otherwise the reason. A criterion + # with such a condition is skipped. + def self.problem(conditions) + Array(conditions).each do |c| + parse_path(c["path"].to_s) + next unless c.key?("matches") + + reason = EcmaRegexp.problem(c["matches"]) + return "regular expression #{c['matches'].to_s.inspect} in applies_if #{reason}" if reason + end + nil + rescue Unsupported => e + e.message + end + + # [member, nil, nil] or [member, "match" | "search", pattern] + def self.parse_path(path) + if (m = path.match(SIMPLE)) + [m[1], nil, nil] + elsif (m = path.match(FILTER)) + [m[1], m[2], string_literal(m[3].rstrip)] + else + raise Unsupported, "applies_if path #{path} is not supported in this version" + end + end + + # A JSONPath string literal (RFC 9535, 2.3.1.1). + def self.string_literal(literal) + quote = literal[0] + unless literal.size >= 2 && %w[' "].include?(quote) && literal[-1] == quote + raise Unsupported, "applies_if: #{literal} is not a JSONPath string literal" + end + + chars = literal[1...-1].chars + out = +"" + until chars.empty? + c = chars.shift + if c == "\\" + out << unescape(chars, quote, literal) + elsif c == quote || c.ord < 0x20 + raise Unsupported, "applies_if: #{literal} is not a valid JSONPath string literal" + else + out << c + end + end + out + end + + def self.unescape(chars, quote, literal) + e = chars.shift + return e if e == quote + return ESCAPES[e] if ESCAPES.key?(e) + raise Unsupported, "applies_if: invalid escape in JSONPath string literal #{literal}" unless e == "u" + + code = hex4(chars, literal) + if (0xD800..0xDBFF).cover?(code) + raise Unsupported, "applies_if: lone surrogate in #{literal}" unless chars.shift(2) == ["\\", "u"] + + low = hex4(chars, literal) + raise Unsupported, "applies_if: lone surrogate in #{literal}" unless (0xDC00..0xDFFF).cover?(low) + + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00) + elsif (0xDC00..0xDFFF).cover?(code) + raise Unsupported, "applies_if: lone surrogate in #{literal}" + end + code.chr(Encoding::UTF_8) + end + + def self.hex4(chars, literal) + hex = chars.shift(4).join + raise Unsupported, "applies_if: invalid \\u escape in #{literal}" unless hex.match?(/\A\h{4}\z/) + + hex.to_i(16) + end + def initialize(condition, passport) @c = condition @passport = passport @@ -15,24 +109,28 @@ def initialize(condition, passport) def holds? values = select - return values.any? == @c["exists"] if @c.key?("exists") + return !values.empty? == @c["exists"] if @c.key?("exists") return values.any? { |v| v == @c["equals"] } if @c.key?("equals") return values.any? { |v| @c["in"].include?(v) } if @c.key?("in") - return values.any? { |v| v.to_s.match?(Regexp.new(@c["matches"])) } if @c.key?("matches") + return values.any? { |v| EcmaRegexp.search?(@c["matches"], v.to_s) } if @c.key?("matches") - values.any? + !values.empty? end private def select - path = @c["path"].to_s - if (m = path.match(SIMPLE)) - @passport.key?(m[1]) ? [@passport[m[1]]] : [] - elsif (m = path.match(SEARCH)) - Array(@passport[m[1]]).select { |v| v.to_s.match?(Regexp.new(m[2])) } - else - raise ArgumentError, "unsupported applies_if path #{path}" - end + member, function, pattern = self.class.parse_path(@c["path"].to_s) + return [] unless @passport.is_a?(Hash) && @passport.key?(member) + + value = @passport[member] + return [value] unless function + + candidates = case value + when Array then value + when Hash then value.values + else [] + end + candidates.select { |v| IRegexp.public_send("#{function}?", pattern, v) } end end diff --git a/app/services/ecma_regexp.rb b/app/services/ecma_regexp.rb index 794a12f..fd78e4a 100644 --- a/app/services/ecma_regexp.rb +++ b/app/services/ecma_regexp.rb @@ -1,5 +1,6 @@ # Regular expressions as CRITERIA-FORMAT.md of dpp-criteria defines them for -# `matches` ("Regular expressions"): ECMA-262 syntax without flags, searched +# `matches` in JSON and header assertions, including applies_if ("Regular +# expressions"): ECMA-262 syntax without flags, searched # anywhere in the value (no implicit anchoring), case-sensitive. # # Ruby's own Regexp differs from ECMA-262 in ways that change results, above @@ -14,10 +15,11 @@ # Patterns that are not valid ECMA-262 raise Invalid. Valid patterns that use # features outside the portable subset of CRITERIA-FORMAT.md, which dpplint # does not evaluate (lookaround, named groups, backreferences, legacy octal -# escapes, \k, lone surrogates), raise Unsupported. Known remaining deviation: -# characters outside the Basic Multilingual Plane are one character here, two -# UTF-16 code units in ECMA-262 (relevant only for `.`, classes and -# quantifiers applied to such a character). +# escapes, \k, lone surrogates), raise Unsupported. Characters outside the +# Basic Multilingual Plane count as one character here, as two UTF-16 code +# units in ECMA-262; CRITERIA-FORMAT.md leaves results that depend on this +# undefined. Regular expressions inside JSONPath (match(), search()) are +# I-Regexp, see IRegexp. class EcmaRegexp class Error < StandardError; end class Invalid < Error; end diff --git a/app/services/i_regexp.rb b/app/services/i_regexp.rb new file mode 100644 index 0000000..181952b --- /dev/null +++ b/app/services/i_regexp.rb @@ -0,0 +1,235 @@ +# I-Regexp (RFC 9485), the regular expressions of the JSONPath functions +# match() and search() (RFC 9535, sections 2.4.6 and 2.4.7), as CRITERIA-FORMAT.md +# of dpp-criteria requires for regular expressions inside a JSONPath expression. +# +# The pattern is parsed with the ABNF of RFC 9485, section 3, and translated +# into an equivalent Ruby Regexp. `^` and `$` outside a character class are +# anchors at the start and end of the whole value: the ABNF lists them as +# ordinary characters, but the mappings of RFC 9485, section 5, and the +# JSONPath Compliance Test Suite (match(): "explicit caret", "explicit dollar") +# treat them as anchors, and dpplint follows that. Further differences to Ruby +# and ECMA-262 that matter: `.` matches any character except LF and CR; only the escapes \( \) \* \+ \- \. \? \[ \\ \] +# \^ \n \r \t \{ \| \} and the category escapes \p{..} and \P{..} exist (no \d, +# \w, \s); there are no lazy quantifiers, no {,m} and no non-capturing or other +# special groups. Matching works on code points and is case-sensitive. +# +# match() holds if the entire value matches, search() if a substring does. +# Following RFC 9535, the result is false if the value is not a string or the +# pattern does not conform to RFC 9485. +class IRegexp + class Invalid < StandardError; end + + CATEGORIES = %w[L Ll Lm Lo Lt Lu M Mc Me Mn N Nd Nl No P Pc Pd Pe Pf Pi Po Ps Z Zl Zp Zs S Sc Sk Sm So C Cc Cf Cn Co].freeze + SINGLE_ESCAPES = { "n" => 0x0A, "r" => 0x0D, "t" => 0x09 }.freeze + SINGLE_ESCAPABLE = ["(", ")", "*", "+", "-", ".", "?", "[", "\\", "]", "^", "{", "|", "}"].freeze + # Characters that are not NormalChar (RFC 9485): ( ) * + . ? [ \ ] { | } + SPECIAL = ["(", ")", "*", "+", ".", "?", "[", "\\", "]", "{", "|", "}"].freeze + RANGE_QUANTIFIER = /\A\{(\d+)(?:(,)(\d*))?\}/ + + # match(): the entire value matches the pattern. + def self.match?(pattern, value) + return false unless value.is_a?(String) + + Regexp.new("\\A(?:#{translate(pattern)})\\z").match?(value) + rescue Invalid + false + end + + # search(): a substring of the value matches the pattern. + def self.search?(pattern, value) + return false unless value.is_a?(String) + + Regexp.new(translate(pattern)).match?(value) + rescue Invalid + false + end + + # nil for a pattern that conforms to RFC 9485, otherwise a reason. + def self.problem(pattern) + translate(pattern) + nil + rescue Invalid => e + "is not a valid I-Regexp (RFC 9485): #{e.message}" + end + + def self.translate(pattern) + raise Invalid, "not a string" unless pattern.is_a?(String) + + source = new(pattern).translate + Regexp.new(source) + source + rescue RegexpError => e + raise Invalid, e.message + end + + def initialize(pattern) + @src = pattern.chars + @pos = 0 + @out = +"" + end + + def translate + regexp + raise Invalid, "unmatched )" unless eof? + + @out + end + + private + + def peek(offset = 0) = @src[@pos + offset] + def take = (@pos += 1; @src[@pos - 1]) + def eof? = @pos >= @src.size + def rest = @src[@pos..].join + + def regexp + branch + while peek == "|" + take + @out << "|" + branch + end + end + + def branch + piece until eof? || peek == "|" || peek == ")" + end + + def piece + start = @out.size + atom + return unless quantifier? + + @out.insert(start, "(?:") << ")" + quantifier + raise Invalid, "quantifier without an atom at #{peek}" if quantifier? + end + + def atom + c = take + case c + when "(" then group + when "." then @out << "[^\\n\\r]" + when "[" then char_class + when "^" then @out << "\\A" + when "$" then @out << "\\z" + when "\\" then @out << escape_outside + else + raise Invalid, "#{c} is not allowed here" if SPECIAL.include?(c) + + @out << char(c.ord) + end + end + + def group + @out << "(?:" + regexp + raise Invalid, "missing )" unless take == ")" + + @out << ")" + end + + def quantifier? = %w[* + ?].include?(peek) || (peek == "{" && rest.match?(RANGE_QUANTIFIER)) + + def quantifier + if %w[* + ?].include?(peek) + @out << take + return + end + m = rest.match(RANGE_QUANTIFIER) + @pos += m[0].size + min = m[1].to_i + max = m[3].to_s.empty? ? nil : m[3].to_i + raise Invalid, "quantifier {#{min},#{max}} with maximum below minimum" if max && max < min + + @out << (m[2] ? "{#{min},#{max}}" : "{#{min}}") + end + + def escape_outside + raise Invalid, "\\ at end of pattern" if eof? + + c = take + return category(c) if %w[p P].include?(c) + + char(single_escape(c)) + end + + def single_escape(c) + return SINGLE_ESCAPES[c] if SINGLE_ESCAPES.key?(c) + return c.ord if SINGLE_ESCAPABLE.include?(c) + + raise Invalid, "\\#{c} is not an I-Regexp escape" + end + + def category(c) + raise Invalid, "\\#{c} must be followed by {" unless take == "{" + + name = +"" + name << take until eof? || peek == "}" + raise Invalid, "missing } after \\#{c}{#{name}" unless take == "}" + raise Invalid, "unknown category #{name}" unless CATEGORIES.include?(name) + + "\\#{c}{#{name}}" + end + + # charClassExpr = "[" [ "^" ] ( "-" / CCE1 ) *CCE1 [ "-" ] "]" + def char_class + negate = peek == "^" + take if negate + items = +"" + first = true + loop do + raise Invalid, "missing ]" if eof? + + if peek == "]" + raise Invalid, "empty character class" if first + + take + break + end + if peek == "-" + raise Invalid, "- inside a character class must be escaped" unless first || peek(1) == "]" + + take + items << char("-".ord) + else + items << class_element + end + first = false + end + @out << "[#{'^' if negate}#{items}]" + end + + # CCE1 = ( CCchar [ "-" CCchar ] ) / charClassEsc + def class_element + if peek == "\\" && %w[p P].include?(peek(1)) + @pos += 2 + return category(@src[@pos - 1]) + end + from = class_char + return char(from) unless peek == "-" && peek(1) != "]" + + take + to = class_char + raise Invalid, "range out of order in character class" if to < from + + "#{char(from)}-#{char(to)}" + end + + # CCchar: any character except - [ \ ], or a SingleCharEsc + def class_char + raise Invalid, "missing ]" if eof? + + c = take + if c == "\\" + raise Invalid, "\\ at end of pattern" if eof? + + return single_escape(take) + end + raise Invalid, "#{c} inside a character class must be escaped" if ["-", "[", "]"].include?(c) + + c.ord + end + + def char(code) = format("\\u{%X}", code) +end diff --git a/app/services/passport_linter.rb b/app/services/passport_linter.rb index 43f72d8..2a7e77e 100644 --- a/app/services/passport_linter.rb +++ b/app/services/passport_linter.rb @@ -54,6 +54,9 @@ def evaluate(criterion, passport, reports) return base.merge(result: "skipped", reason: "check type #{check['type']} is not implemented in this version") end return base.merge(result: "skipped", reason: "passport could not be retrieved") if passport.nil? + if (problem = AppliesIf.problem(criterion["applies_if"])) + return base.merge(result: "skipped", reason: problem) + end if criterion["applies_if"] && !AppliesIf.holds?(criterion["applies_if"], passport) return base.merge(result: "skipped", reason: "condition not met") end diff --git a/app/services/resolve_check.rb b/app/services/resolve_check.rb index 362a82d..0055d45 100644 --- a/app/services/resolve_check.rb +++ b/app/services/resolve_check.rb @@ -3,8 +3,10 @@ # The first request follows the identifier with the Accept header of the # criterion (`accept`). Without `expect`, it has to resolve to a single # passport object whose uniqueProductIdentifier equals the identifier. With -# `expect`, its status and content type are checked instead; an expected JSON -# content type also requires the body to be a JSON object. Following "Order of +# `expect`, its status and content type are checked instead. The content type +# is compared as media type without parameters, case-insensitively; for a JSON +# media type (application/json or +json) the body must also be a single JSON +# object, which belongs to the content type check. Following "Order of # evaluation within a request" in CRITERIA-FORMAT.md, the header fields # (`headers`, see HeaderAssertion) are evaluated only if status and content # type hold; otherwise they give no message of their own. @@ -73,15 +75,32 @@ def status_and_content_type(res, expect) out << violation("HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}") end if (type = expect["content_type"]) - if res.media_type != type.downcase + expected_type = media_type(type) + if res.media_type != expected_type out << violation("Content-Type is #{res.content_type.presence || 'missing'}, expected #{type}") - elsif type.downcase == "application/json" && !json_object?(res.body) - out << violation("response is not a single JSON object") + elsif json_media_type?(expected_type) + json = parse_json(res.body) + if json == :invalid then out << violation("response is not valid JSON") + elsif !json.is_a?(Hash) then out << violation("response is not a single JSON object") + end end end out end + # The media type without parameters, lower case (CRITERIA-FORMAT.md, "Order + # of evaluation within a request"). + def media_type(value) = value.to_s.split(";").first.to_s.strip.downcase + + # application/json or a type with the structured syntax suffix +json. + def json_media_type?(type) = type == "application/json" || type.end_with?("+json") + + def parse_json(body) + JSON.parse(body.to_s) + rescue JSON::ParserError + :invalid + end + def resolves_to_passport(res) return [violation("HTTP status is #{res.status}, expected 2xx")] unless res.success? @@ -96,9 +115,4 @@ def resolves_to_passport(res) def violation(message) = { severity: "violation", message: message } - def json_object?(body) - JSON.parse(body).is_a?(Hash) - rescue JSON::ParserError - false - end end diff --git a/test/services/applies_if_test.rb b/test/services/applies_if_test.rb new file mode 100644 index 0000000..0862fe1 --- /dev/null +++ b/test/services/applies_if_test.rb @@ -0,0 +1,77 @@ +require "test_helper" + +class AppliesIfTest < ActiveSupport::TestCase + BAT_002 = [{ "path" => "$.contentSpecificationIds[?search(@, '[Bb]atter')]", "exists" => true }].freeze + PCDS_008 = [{ "path" => "$.contentSpecificationIds[?search(@, '59040|PCDS|pcds')]", "exists" => true }].freeze + + def holds?(conditions, passport) = AppliesIf.holds?(conditions, passport) + + test "DPP-BAT-002: search() with I-Regexp on contentSpecificationIds" do + assert holds?(BAT_002, { "contentSpecificationIds" => ["urn:x", "EN 18222 Battery"] }) + assert holds?(BAT_002, { "contentSpecificationIds" => ["battery-passport"] }) + refute holds?(BAT_002, { "contentSpecificationIds" => ["BATTERY"] }) + refute holds?(BAT_002, { "contentSpecificationIds" => [] }) + refute holds?(BAT_002, {}) + end + + test "DPP-PCDS-008: alternatives in search()" do + assert holds?(PCDS_008, { "contentSpecificationIds" => ["DIN SPEC 59040"] }) + assert holds?(PCDS_008, { "contentSpecificationIds" => ["urn:pcds"] }) + refute holds?(PCDS_008, { "contentSpecificationIds" => ["Pcds"] }) + end + + test "match() needs the entire element, search() a substring" do + passport = { "contentSpecificationIds" => ["battery-passport"] } + refute holds?([{ "path" => "$.contentSpecificationIds[?match(@, '[Bb]atter')]", "exists" => true }], passport) + assert holds?([{ "path" => "$.contentSpecificationIds[?match(@, '[Bb]atter.*')]", "exists" => true }], passport) + assert holds?([{ "path" => "$.contentSpecificationIds[?search(@, '[Bb]atter')]", "exists" => true }], passport) + end + + test "search() is false for elements that are not strings" do + refute holds?(PCDS_008, { "contentSpecificationIds" => [59040] }) + end + + test "the filter selects array elements or member values, nothing from a string" do + assert holds?(BAT_002, { "contentSpecificationIds" => { "a" => "Battery" } }) + refute holds?(BAT_002, { "contentSpecificationIds" => "Battery" }) + end + + test "a pattern that does not conform to RFC 9485 selects nothing (RFC 9535)" do + conditions = [{ "path" => "$.contentSpecificationIds[?search(@, '\\\\d+')]", "exists" => true }] + assert_nil AppliesIf.problem(conditions) + refute holds?(conditions, { "contentSpecificationIds" => ["59040"] }) + absent = [{ "path" => "$.contentSpecificationIds[?search(@, '\\\\d+')]", "exists" => false }] + assert holds?(absent, { "contentSpecificationIds" => ["59040"] }) + end + + test "JSONPath string literals: double quotes and escapes (RFC 9535, 2.3.1.1)" do + passport = { "contentSpecificationIds" => ["a.c", "abc", "it's"] } + assert holds?([{ "path" => '$.contentSpecificationIds[?search(@, "[Bb]")]', "exists" => true }], { "contentSpecificationIds" => ["Battery"] }) + assert_equal "a\\.c", AppliesIf.string_literal("'a\\\\.c'") + refute holds?([{ "path" => "$.contentSpecificationIds[?match(@, 'a\\\\.c')]", "exists" => true }], { "contentSpecificationIds" => ["abc"] }) + assert holds?([{ "path" => "$.contentSpecificationIds[?match(@, 'a\\\\.c')]", "exists" => true }], passport) + assert holds?([{ "path" => "$.contentSpecificationIds[?match(@, 'it\\'s')]", "exists" => true }], passport) + assert holds?([{ "path" => "$.contentSpecificationIds[ ?search( @ , 'bc' ) ]", "exists" => true }], passport) + end + + test "exists holds for a member whose value is null or false" do + assert holds?([{ "path" => "$.facilityId", "exists" => true }], { "facilityId" => nil }) + assert holds?([{ "path" => "$.flag", "exists" => true }], { "flag" => false }) + assert holds?([{ "path" => "$.facilityId", "exists" => false }], {}) + end + + test "matches of the assertion is ECMA-262, searched in the value" do + assert holds?([{ "path" => "$.granularity", "matches" => "^(item|batch)$" }], { "granularity" => "item" }) + refute holds?([{ "path" => "$.granularity", "matches" => "^(item|batch)$" }], { "granularity" => "model\nitem" }) + end + + test "problem: unsupported path, invalid JSONPath string literal, unusable matches" do + assert_match(/\Aapplies_if path \$\.a\.b is not supported/, AppliesIf.problem([{ "path" => "$.a.b", "exists" => true }])) + assert_match(/not a valid JSONPath string literal/, AppliesIf.problem([{ "path" => "$.a[?search(@, 'x'y')]", "exists" => true }])) + assert_match(/invalid escape/, AppliesIf.problem([{ "path" => "$.a[?search(@, '\\d')]", "exists" => true }])) + assert_match(/\Aregular expression "\(\?i\)item" in applies_if is not a valid ECMA-262 regular expression/, + AppliesIf.problem([{ "path" => "$.granularity", "matches" => "(?i)item" }])) + assert_nil AppliesIf.problem(BAT_002 + PCDS_008) + assert_nil AppliesIf.problem(nil) + end +end diff --git a/test/services/i_regexp_test.rb b/test/services/i_regexp_test.rb new file mode 100644 index 0000000..679300a --- /dev/null +++ b/test/services/i_regexp_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +# Expected results agree with the JSONPath Compliance Test Suite where it has a +# case, and with the Python implementation jsonpath-rfc9535. +class IRegexpTest < ActiveSupport::TestCase + test "match() needs the entire value, search() a substring" do + assert IRegexp.search?("batter", "battery") + refute IRegexp.match?("batter", "battery") + assert IRegexp.match?("batter.*", "battery") + refute IRegexp.match?("a|b", "ab") + assert IRegexp.match?("(a|b)+", "abab") + end + + test "patterns of DPP-BAT-002 and DPP-PCDS-008" do + assert IRegexp.search?("[Bb]atter", "EN 18222 Battery passport") + assert IRegexp.search?("[Bb]atter", "battery") + refute IRegexp.search?("[Bb]atter", "BATTERY") + refute IRegexp.match?("[Bb]atter", "Battery") + assert IRegexp.search?("59040|PCDS|pcds", "DIN SPEC 59040") + assert IRegexp.search?("59040|PCDS|pcds", "urn:pcds:v1") + refute IRegexp.search?("59040|PCDS|pcds", "Pcds") + end + + test "^ and $ anchor the whole value (Compliance Test Suite: explicit caret, explicit dollar)" do + assert IRegexp.match?("^ab.*", "abc") + refute IRegexp.match?("^ab.*", "xab") + assert IRegexp.match?(".*bc$", "abc") + refute IRegexp.match?(".*bc$", "abcx") + refute IRegexp.search?("^b", "a\nb") + refute IRegexp.search?("a$", "a\nb") + end + + test ". matches any character except LF and CR" do + assert IRegexp.match?(".", "
") + refute IRegexp.search?(".", "\n") + refute IRegexp.search?(".", "\r") + assert IRegexp.match?(".", "😀") + assert IRegexp.match?("a[.b]c", "a.c") + refute IRegexp.match?("a\\.c", "abc") + end + + test "case-sensitive, category escapes" do + refute IRegexp.search?("pcds", "PCDS") + assert IRegexp.match?("\\p{Lu}+", "ABC") + refute IRegexp.match?("\\p{Lu}+", "AbC") + assert IRegexp.match?("[\\p{Nd}x]", "5") + assert IRegexp.match?("\\P{L}", "1") + end + + test "not a string: false" do + refute IRegexp.search?("5", 5) + refute IRegexp.match?("true", true) + refute IRegexp.search?("a", nil) + end + + test "patterns that do not conform to RFC 9485 give false and a problem" do + ["\\d", "(?:a)", "a*?", "a{,2}", "[]", "[a-c-e]", "\\p{Xx}", "(a", "a)", "[z-a]", "a{2,1}", "\\w+"].each do |pattern| + assert_match(/\Ais not a valid I-Regexp \(RFC 9485\)/, IRegexp.problem(pattern), pattern) + refute IRegexp.search?(pattern, "a"), pattern + refute IRegexp.match?(pattern, "a"), pattern + end + assert_nil IRegexp.problem("[Bb]atter") + assert_nil IRegexp.problem("59040|PCDS|pcds") + end +end diff --git a/test/services/passport_linter_test.rb b/test/services/passport_linter_test.rb index 63f9adc..94b5eb4 100644 --- a/test/services/passport_linter_test.rb +++ b/test/services/passport_linter_test.rb @@ -72,6 +72,25 @@ def lint(responses, check = CHECK) assert_equal "expect json is not evaluated for check type resolve in this version", result[:reason] end + test "applies_if with a matches pattern that is not valid ECMA-262 skips the criterion with a reason" do + criterion = { "id" => "DPP-ID-099", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", + "applies_if" => [{ "path" => "$.granularity", "matches" => "a**" }], "check" => { "type" => "did", "paths" => ["$.facilityId"] } } + report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion]), resolver: FakeResolver.new({})) + .run(passport: { "granularity" => "item" }) + result = report[:criteria].first + assert_equal "skipped", result[:result] + assert_match(/\Aregular expression "a\*\*" in applies_if is not a valid ECMA-262 regular expression/, result[:reason]) + end + + test "applies_if with search() whose condition does not hold skips the criterion" do + criterion = { "id" => "DPP-BAT-002", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", + "applies_if" => [{ "path" => "$.contentSpecificationIds[?search(@, '[Bb]atter')]", "exists" => true }], + "check" => { "type" => "did", "paths" => ["$.facilityId"] } } + report = PassportLinter.new(catalogue: FakeCatalogue.new([criterion]), resolver: FakeResolver.new({})) + .run(passport: { "contentSpecificationIds" => ["BATTERY"] }) + assert_equal({ result: "skipped", reason: "condition not met" }, report[:criteria].first.slice(:result, :reason)) + end + test "a pattern that is not valid ECMA-262 skips the criterion with a reason, without a request" do check = CHECK.merge("expect" => CHECK["expect"].merge("headers" => [{ "name" => "Vary", "matches" => "(?i)accept" }])) result = lint({}, check)[:criteria].first diff --git a/test/services/resolve_check_test.rb b/test/services/resolve_check_test.rb index 82d6bcd..b3755bd 100644 --- a/test/services/resolve_check_test.rb +++ b/test/services/resolve_check_test.rb @@ -48,6 +48,36 @@ def severities(check, responses) = messages(check, responses).map { |m| m[:sever assert_match(/single JSON object/, violations(check, default: [200, "application/json", "[1,2]"]).first) end + # content_type (dpp-criteria 203202e): media type without parameters, + # case-insensitive; JSON media types need a body that is a single JSON object + + test "content type: parameters and case are ignored" do + check = { "type" => "resolve", "expect" => { "status" => [200], "content_type" => "application/json" } } + assert_empty violations(check, default: [200, "Application/JSON ; Charset=UTF-8", passport]) + html = { "type" => "resolve", "accept" => "text/html", "expect" => { "status" => [200], "content_type" => "Text/HTML" } } + assert_empty violations(html, "text/html" => [200, "text/html;charset=utf-8", ""]) + end + + test "content type: application/ld+json is a JSON media type" do + check = { "type" => "resolve", "accept" => "application/ld+json", "expect" => { "status" => [200], "content_type" => "application/ld+json" } } + assert_empty violations(check, "application/ld+json" => [200, "application/ld+json; charset=utf-8", passport]) + assert_equal ["response is not valid JSON"], violations(check, "application/ld+json" => [200, "application/ld+json", "{no json"]) + assert_equal ["response is not a single JSON object"], violations(check, "application/ld+json" => [200, "APPLICATION/LD+JSON", "[#{passport}]"]) + assert_equal ["Content-Type is application/json, expected application/ld+json"], + violations(check, "application/ld+json" => [200, "application/json", passport]) + end + + test "content type: a body that is not JSON fails the content type check, headers are not evaluated" do + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => [{ "name" => "Vary", "contains" => "Accept" }]) } + assert_equal ["response is not valid JSON"], violations(check, default: [200, "application/json", "", { "vary" => ["Origin"] }]) + assert_equal ["response is not a single JSON object"], violations(check, default: [200, "application/json", "2", { "vary" => ["Origin"] }]) + end + + test "content type: a type that is not JSON does not require a JSON body" do + check = { "type" => "resolve", "accept" => "text/plain", "expect" => { "status" => [200], "content_type" => "text/plain" } } + assert_empty violations(check, "text/plain" => [200, "text/plain; charset=utf-8", "{no json"]) + end + test "HTML requested but JSON delivered fails" do check = { "type" => "resolve", "accept" => "text/html", "expect" => { "status" => [200], "content_type" => "text/html" } } result = violations(check, "text/html" => [200, "application/json", passport], default: [200, "application/json", passport])