diff --git a/README.md b/README.md index 6a5b9a4..15833e6 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,15 @@ 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, content type and header fields (`exists`, - `equals`, `contains`, `matches`; field names case-insensitive, several - fields of one name combined with commas). `further_requests` are sent - afterwards with their own Accept header and evaluated independently. A + `expect` checks status and content type first 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 + anywhere in the value and case-sensitive (`^` and `$` anchor the whole + value); a criterion with a pattern that is not valid ECMA-262, or that uses + lookaround, named groups or backreferences, is skipped with the reason. + `further_requests` are sent afterwards with their own Accept header and + evaluated independently. A criterion fails if a check with severity error fails; if only checks with `severity: warning` fail, the result is `warning`. - Criteria with `check.type: did` send the DIDs of the passport to diff --git a/app/services/ecma_regexp.rb b/app/services/ecma_regexp.rb new file mode 100644 index 0000000..794a12f --- /dev/null +++ b/app/services/ecma_regexp.rb @@ -0,0 +1,298 @@ +# Regular expressions as CRITERIA-FORMAT.md of dpp-criteria defines them for +# `matches` ("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 +# all: `^` and `$` are line anchors in Ruby but anchor the whole value in +# ECMA-262 without the m flag; `.` also matches U+2028/U+2029 in Ruby; `\s` +# and `\b` are defined differently; `[a&&b]`, `[[a]`, `a*+` and `a{,2}` mean +# something else or nothing in ECMA-262. The pattern is therefore parsed as +# ECMA-262 (including the Annex B rules that apply without flags, e.g. `\A` +# is the letter A and a lone `{` is a literal) and translated into an +# equivalent Ruby Regexp. +# +# 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). +class EcmaRegexp + class Error < StandardError; end + class Invalid < Error; end + class Unsupported < Error; end + + LINE_TERMINATORS = "\\n\\r\\u2028\\u2029".freeze + WHITESPACE = "\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff".freeze + CLASS_ESCAPES = { "d" => "0-9", "w" => "A-Za-z0-9_", "s" => WHITESPACE }.freeze + CONTROL_ESCAPES = { "f" => 0x0C, "n" => 0x0A, "r" => 0x0D, "t" => 0x09, "v" => 0x0B }.freeze + WORD = "[A-Za-z0-9_]".freeze + BOUNDARY = "(?:(?<=#{WORD})(?!#{WORD})|(? e + raise Unsupported, e.message + end + + # Whether the pattern is found anywhere in the value. + def self.search?(pattern, value) + compile(pattern).match?(utf8(value)) + end + + # nil for a pattern dpplint can evaluate, otherwise a reason. + def self.problem(pattern) + compile(pattern) + nil + rescue Invalid => e + "is not a valid ECMA-262 regular expression (#{e.message})" + rescue Unsupported => e + "uses a feature outside the portable subset of CRITERIA-FORMAT.md that dpplint does not evaluate (#{e.message})" + end + + def self.utf8(value) + s = value.to_s.dup.force_encoding(Encoding::UTF_8) + s.valid_encoding? ? s : s.scrub + end + + def initialize(pattern) + @src = pattern.chars + @pos = 0 + @out = +"" + end + + def translate + disjunction(0) + @out + end + + private + + def peek(offset = 0) = @src[@pos + offset] + def rest = @src[@pos..].join + def take = (@pos += 1; @src[@pos - 1]) + def eof? = @pos >= @src.size + + def disjunction(depth) + loop do + alternative + break if eof? + + case peek + when "|" then take; @out << "|" + when ")" + raise Invalid, "unmatched )" if depth.zero? + + break + end + end + end + + def alternative + term until eof? || peek == "|" || peek == ")" + end + + def term + start = @out.size + kind = atom + quantifier(start, kind) + end + + # Emits one atom or assertion; returns :atom or :assertion. + def atom + c = take + case c + when "^" then @out << "\\A"; :assertion + when "$" then @out << "\\z"; :assertion + when "\\" then escape + when "(" then group; :atom + when "[" then char_class; :atom + when "." then @out << "[^#{LINE_TERMINATORS}]"; :atom + when "*", "+", "?" then raise Invalid, "nothing to repeat before #{c}" + when "{" + @pos -= 1 + raise Invalid, "nothing to repeat before {" if rest.match?(BRACED) + + take + literal(c) + else literal(c) + end + end + + def literal(c) + @out << char(c.ord) + :atom + end + + def quantifier(start, kind) + q = quantifier_at + return unless q + raise Invalid, "nothing to repeat after an assertion" if kind == :assertion + + @out.insert(start, "(?:") << ")" + @pos += q[:length] + lazy = peek == "?" + take if lazy + @out << q[:ruby] + @out << "?" if lazy && !q[:exact] + raise Invalid, "nothing to repeat before #{peek}" if quantifier_at + end + + def quantifier_at + case peek + when "*", "+", "?" then { length: 1, ruby: peek, exact: false } + when "{" + m = rest.match(BRACED) + return unless m + + min = m[1].to_i + max = m[3].to_s.empty? ? nil : m[3].to_i + raise Invalid, "numbers out of order in {} quantifier" if max && max < min + + ruby = m[2] ? "{#{min},#{max}}" : "{#{min}}" + { length: m[0].size, ruby: ruby, exact: m[2].nil? } + end + end + + def group + if peek == "?" + take + nxt = take + case nxt + when ":" then @out << "(?:" + when "=", "!" then raise Unsupported, "lookahead" + when "<" + raise Unsupported, "lookbehind" if %w[= !].include?(peek) + + raise Unsupported, "named groups" + else raise Invalid, "invalid group (?#{nxt}" + end + else + @out << "(" + end + disjunction(1) + raise Invalid, "missing )" unless take == ")" + + @out << ")" + end + + # Escape outside a character class; returns :atom or :assertion. + def escape + raise Invalid, "\\ at end of pattern" if eof? + + c = take + case c + when "b" then @out << BOUNDARY; :assertion + when "B" then @out << NON_BOUNDARY; :assertion + when "d", "w", "s" then @out << "[#{CLASS_ESCAPES[c]}]"; :atom + when "D", "W", "S" then @out << "[^#{CLASS_ESCAPES[c.downcase]}]"; :atom + else + code = escaped_code(c, in_class: false) + @out << (code.nil? ? char("\\".ord) : char(code)) + :atom + end + end + + # Code point of a character escape (the character after the backslash is + # already taken). nil means Annex B `\c` without a control letter: a literal + # backslash, the c is read again as a literal. + def escaped_code(c, in_class:) + return CONTROL_ESCAPES[c] if CONTROL_ESCAPES.key?(c) + + case c + when "c" + if peek&.match?(/[A-Za-z]/) || (in_class && peek&.match?(/[0-9_]/)) + take.ord % 32 + else + @pos -= 1 + nil + end + when "0" + raise Unsupported, "legacy octal escape" if peek&.match?(/[0-9]/) + + 0 + when "1".."9" then raise Unsupported, in_class ? "legacy octal escape" : "backreference or legacy octal escape" + when "k" then raise Unsupported, "\\k" + when "x" then hex_escape(2) || "x".ord + when "u" then unicode_escape || "u".ord + else c.ord + end + end + + def hex_escape(digits) + s = @src[@pos, digits]&.join + return unless s && s.size == digits && s.match?(HEX) + + @pos += digits + s.to_i(16) + end + + def unicode_escape + code = hex_escape(4) + return unless code + return code unless (0xD800..0xDFFF).cover?(code) + + if code <= 0xDBFF && peek == "\\" && peek(1) == "u" + @pos += 2 + low = hex_escape(4) + return 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00) if low && (0xDC00..0xDFFF).cover?(low) + end + raise Unsupported, "lone surrogate" + end + + def char_class + negate = peek == "^" + take if negate + if peek == "]" + take + @out << (negate ? "(?m:.)" : "(?!)") + return + end + items = +"" + until peek == "]" + raise Invalid, "missing ]" if eof? + + items << class_range + end + take + @out << "[#{'^' if negate}#{items}]" + end + + def class_range + from = class_atom + return fragment(from) unless peek == "-" && peek(1) && peek(1) != "]" + + take + to = class_atom + return fragment(from) + char("-".ord) + fragment(to) if from.is_a?(String) || to.is_a?(String) + raise Invalid, "range out of order in character class" if to < from + + "#{char(from)}-#{char(to)}" + end + + # Integer code point, or String for a class escape such as \d. + def class_atom + raise Invalid, "missing ]" if eof? + + c = take + return c.ord unless c == "\\" + raise Invalid, "\\ at end of pattern" if eof? + + e = take + case e + when "b" then 0x08 + when "d", "w", "s" then CLASS_ESCAPES[e] + when "D", "W", "S" then "[^#{CLASS_ESCAPES[e.downcase]}]" + else escaped_code(e, in_class: true) || "\\".ord + end + end + + def fragment(item) = item.is_a?(String) ? item : char(item) + + def char(code) = format("\\u{%X}", code) +end diff --git a/app/services/header_assertion.rb b/app/services/header_assertion.rb index 70998fa..646f3d7 100644 --- a/app/services/header_assertion.rb +++ b/app/services/header_assertion.rb @@ -8,7 +8,9 @@ # - `contains`: the combined value, split at commas and trimmed, has a member # equal to the string, compared case-insensitively ("Vary: *" therefore does # not contain "Accept"); -# - `matches`: the regular expression is found in the combined value; +# - `matches`: the ECMA-262 regular expression is found anywhere in the +# combined value, case-sensitively (see EcmaRegexp); an invalid pattern +# raises EcmaRegexp::Error, callers check patterns beforehand (problem); # - a missing field fails every assertion except `exists: false`; # - `severity: warning` reports a failure as a warning instead of a violation. class HeaderAssertion @@ -29,6 +31,18 @@ def self.field(headers, name) values.empty? ? nil : values.join(", ") end + # The first pattern of `matches` in the assertions that dpplint cannot + # evaluate, as a reason, or nil. + def self.problem(assertions) + Array(assertions).each do |assertion| + next unless assertion.key?("matches") + + reason = EcmaRegexp.problem(assertion["matches"]) + return "regular expression #{assertion['matches'].to_s.inspect} for header #{assertion['name']} #{reason}" if reason + end + nil + end + def initialize(assertion) @assertion = assertion @name = assertion["name"].to_s @@ -49,11 +63,9 @@ def failure(op, expected, value) holds = case op when "equals" then value == expected.to_s when "contains" then value.split(",").map(&:strip).any? { |member| member.casecmp?(expected.to_s) } - when "matches" then Regexp.new(expected.to_s).match?(value) + when "matches" then EcmaRegexp.search?(expected, value) end holds ? nil : "header #{@name} is #{value.inspect}, expected #{describe(op, expected)}" - rescue RegexpError => e - "regular expression #{expected.inspect} for header #{@name} is invalid (#{e.message})" end def exists(expected, value) diff --git a/app/services/passport_linter.rb b/app/services/passport_linter.rb index 7cd5e5d..43f72d8 100644 --- a/app/services/passport_linter.rb +++ b/app/services/passport_linter.rb @@ -79,6 +79,9 @@ def resolve(base, check) if (keys = resolve_check.unsupported).any? return base.merge(result: "skipped", reason: "expect #{keys.join(', ')} is not evaluated for check type resolve in this version") end + if (problem = resolve_check.pattern_problem) + return base.merge(result: "skipped", reason: problem) + end messages = resolve_check.messages base.merge(result: result_for(messages), messages: messages) diff --git a/app/services/resolve_check.rb b/app/services/resolve_check.rb index 9727c19..362a82d 100644 --- a/app/services/resolve_check.rb +++ b/app/services/resolve_check.rb @@ -3,9 +3,11 @@ # 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, content type and header fields (`headers`, see -# HeaderAssertion) are checked instead; an expected JSON content type also -# requires the body to be a JSON object. +# `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 +# 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. # # `further_requests` are sent after the first request to the same identifier, # each with its own `accept` and `expect`, and are evaluated independently of @@ -24,8 +26,14 @@ def initialize(check, product_id, resolver) # Keys of an `expect` block this version does not evaluate. A criterion that # uses one is skipped rather than passed without that part. def unsupported - expects = [@check["expect"], *Array(@check["further_requests"]).map { |r| r["expect"] }] - expects.compact.flat_map { |e| e.keys - SUPPORTED_EXPECT }.uniq + expects.flat_map { |e| e.keys - SUPPORTED_EXPECT }.uniq + end + + # A reason if a regular expression of the criterion cannot be evaluated + # (not valid ECMA-262 or outside the portable subset), otherwise nil. Such a + # criterion is skipped. + def pattern_problem + expects.lazy.map { |e| HeaderAssertion.problem(e["headers"]) }.find(&:itself) end # Returns messages { severity: "violation" | "warning", message: }; none means passed. @@ -39,6 +47,8 @@ def messages private + def expects = [@check["expect"], *Array(@check["further_requests"]).map { |r| r["expect"] }].compact + def request(accept, expect, severity: nil, label: nil) res = @resolver.get(@product_id, accept: accept) out = if res.error then [violation(res.error)] @@ -49,7 +59,15 @@ def request(accept, expect, severity: nil, label: nil) label ? out.map { |m| m.merge(message: "#{label}: #{m[:message]}") } : out end + # status and content type first; header fields only if both hold. def expected(res, expect) + out = status_and_content_type(res, expect) + return out if out.any? + + HeaderAssertion.messages(expect["headers"], res.headers) + end + + def status_and_content_type(res, expect) out = [] if expect["status"] && !expect["status"].include?(res.status) out << violation("HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}") @@ -61,7 +79,7 @@ def expected(res, expect) out << violation("response is not a single JSON object") end end - out + HeaderAssertion.messages(expect["headers"], res.headers) + out end def resolves_to_passport(res) diff --git a/test/services/ecma_regexp_test.rb b/test/services/ecma_regexp_test.rb new file mode 100644 index 0000000..b7ca20e --- /dev/null +++ b/test/services/ecma_regexp_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +# Expected results were taken from JavaScript (new RegExp(pattern).test(value)), +# i.e. ECMA-262 without flags. +class EcmaRegexpTest < ActiveSupport::TestCase + def search?(pattern, value) = EcmaRegexp.search?(pattern, value) + + test "searched anywhere in the value, not implicitly anchored" do + assert search?("json", "application/ld+json; charset=utf-8") + assert search?("^application/(ld\\+)?json(;|$)", "application/ld+json; charset=utf-8") + assert search?("^application/(ld\\+)?json(;|$)", "application/json") + refute search?("^application/(ld\\+)?json(;|$)", "text/application/json") + end + + test "^ and $ anchor the whole value, not a line" do + refute search?("^b", "a\nb") + refute search?("a$", "a\nb") + refute search?("a$", "a\n") + assert search?("^a", "a\nb") + assert search?("b$", "a\nb") + assert search?("^a\\nb$", "a\nb") + end + + test "case-sensitive" do + refute search?("accept", "Accept") + assert search?("Accept", "Origin, Accept") + end + + test ". does not match line terminators" do + assert search?("a.c", "abc") + refute search?("a.c", "a\nc") + refute search?("^.$", "
") + refute search?("^.$", "\r") + end + + test "\\s, \\b and \\w follow ECMA-262" do + assert search?("\\s", " ") + assert search?("\\s", "") + refute search?("[^\\s]", " ") + refute search?("\\w", "é") + assert search?("\\bfoo\\b", "éfooé") + refute search?("\\Bo", "o") + end + + test "Annex B: identity escapes and lone braces are literals" do + assert search?("\\A", "A") + refute search?("\\Aa", "a") + assert search?("\\h", "h") + assert search?("a{", "a{") + assert search?("a{,2}", "a{,2}") + refute search?("a{,2}", "aa") + assert search?("\\u{2}", "uu") + assert search?("\\p{L}", "p{L}") + end + + test "character classes: no nesting, no intersection" do + assert search?("[[a]", "[") + assert search?("[a&&b]", "&") + refute search?("[a&&b]", "c") + assert search?("[\\d-z]", "-") + refute search?("[]", "a") + assert search?("[^]", "\n") + assert search?("[\\b]", "\b") + end + + test "quantifiers" do + assert search?("^(?:ab)+$", "abab") + assert search?("^a{2}?$", "aa") + refute search?("^a{2}?$", "a") + assert search?("^[0-9]{4}-[0-9]{2}-[0-9]{2}T", "2026-09-28T10:00:00Z") + end + + test "escapes of characters" do + assert search?("\\x41", "A") + assert search?("\\x4", "x4") + assert search?("\\u0041", "A") + assert search?("x\\cAy", "x\u0001y") + assert search?("\\c", "\\c") + assert search?("\\uD83D\\uDE00", "😀") + end + + test "patterns that are not valid ECMA-262" do + ["(?i)a", "a**", "a*+", "^*", "\\b+", "a{2,1}", "[z-a]", "(?>a)", "(", ")", "a\\", "[a", "{2}"].each do |pattern| + assert_match(/\Ais not a valid ECMA-262 regular expression/, EcmaRegexp.problem(pattern), pattern) + end + end + + test "valid patterns outside the portable subset are not evaluated" do + ["(?=a)", "(?a)", "(a)\\1", "\\01", "\\k", "\\uD83D"].each do |pattern| + assert_match(/\Auses a feature outside the portable subset/, EcmaRegexp.problem(pattern), pattern) + end + assert_nil EcmaRegexp.problem("^Accept$") + end + + test "values that are not valid UTF-8 do not raise" do + refute search?("é", "\xFF".b) + end +end diff --git a/test/services/header_assertion_test.rb b/test/services/header_assertion_test.rb index bd837ef..f33d7a4 100644 --- a/test/services/header_assertion_test.rb +++ b/test/services/header_assertion_test.rb @@ -37,13 +37,38 @@ def holds?(assertion, headers) = problems(assertion, headers).empty? refute holds?({ "name" => "Vary", "contains" => "Accept" }, { "vary" => ["*"] }) end - test "matches searches the whole value" do - assert holds?({ "name" => "Cache-Control", "matches" => "max-age=\\d+" }, { "cache-control" => ["public, max-age=300"] }) - refute holds?({ "name" => "Cache-Control", "matches" => "\\Ano-store\\z" }, { "cache-control" => ["public, no-store"] }) + test "matches searches the pattern anywhere in the value, without implicit anchoring" do + assert holds?({ "name" => "Cache-Control", "matches" => "max-age=[0-9]+" }, { "cache-control" => ["public, max-age=300"] }) + refute holds?({ "name" => "Cache-Control", "matches" => "^no-store$" }, { "cache-control" => ["public, no-store"] }) + assert holds?({ "name" => "Cache-Control", "matches" => "^public, no-store$" }, { "cache-control" => ["public, no-store"] }) end - test "invalid regular expression fails with a message" do - assert_match(/regular expression "\(" for header X-Test is invalid/, problems({ "name" => "X-Test", "matches" => "(" }, { "x-test" => ["a"] }).first) + test "matches: ^ and $ anchor the whole value, also when it contains a line break" do + headers = { "x-test" => ["first\nsecond"] } + refute holds?({ "name" => "X-Test", "matches" => "^second" }, headers) + refute holds?({ "name" => "X-Test", "matches" => "first$" }, headers) + assert holds?({ "name" => "X-Test", "matches" => "^first" }, headers) + assert holds?({ "name" => "X-Test", "matches" => "second$" }, headers) + assert holds?({ "name" => "X-Test", "matches" => "^first\\nsecond$" }, headers) + end + + test "matches is case-sensitive" do + headers = { "cache-control" => ["Max-Age=300"] } + refute holds?({ "name" => "Cache-Control", "matches" => "max-age" }, headers) + assert holds?({ "name" => "Cache-Control", "matches" => "Max-Age" }, headers) + end + + test "matches uses ECMA-262 syntax, where \\A is the letter A" do + refute holds?({ "name" => "Vary", "matches" => "\\AAccept" }, { "vary" => ["Accept"] }) + assert holds?({ "name" => "Vary", "matches" => "\\AAccept" }, { "vary" => ["AAccept"] }) + end + + test "problem names an invalid pattern and a pattern outside the portable subset" do + assert_nil HeaderAssertion.problem([{ "name" => "Vary", "matches" => "^Accept" }, { "name" => "Vary", "exists" => true }]) + assert_match(/\Aregular expression "\(" for header X-Test is not a valid ECMA-262 regular expression/, + HeaderAssertion.problem([{ "name" => "X-Test", "matches" => "(" }])) + assert_match(/\Aregular expression "\(\?=a\)" for header X-Test uses a feature outside the portable subset .*\(lookahead\)/, + HeaderAssertion.problem([{ "name" => "X-Test", "matches" => "(?=a)" }])) end test "missing field fails equals, contains and matches" do diff --git a/test/services/passport_linter_test.rb b/test/services/passport_linter_test.rb index 76a1676..63f9adc 100644 --- a/test/services/passport_linter_test.rb +++ b/test/services/passport_linter_test.rb @@ -52,7 +52,7 @@ def lint(responses, check = CHECK) end test "a failing error check fails the criterion, whatever the warnings" do - report = lint("text/html" => [200, "application/json", '{"a":1}', { "vary" => ["Origin"] }], "*/*" => JSON_OK) + report = lint("text/html" => [200, "application/json", '{"a":1}', { "vary" => ["Origin"] }], "*/*" => [200, "text/html", ""]) result = report[:criteria].first assert_equal "failed", result[:result] assert_equal %w[violation warning], result[:messages].map { |m| m[:severity] } @@ -71,4 +71,18 @@ def lint(responses, check = CHECK) assert_equal "skipped", result[:result] assert_equal "expect json is not evaluated for check type resolve in this version", 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 + assert_equal "skipped", result[:result] + assert_match(/\Aregular expression "\(\?i\)accept" for header Vary is not a valid ECMA-262 regular expression/, result[:reason]) + end + + test "a pattern outside the portable subset skips the criterion with a reason" do + check = CHECK.merge("further_requests" => [{ "accept" => "*/*", "expect" => { "headers" => [{ "name" => "Vary", "matches" => "(?=Accept)" }] } }]) + result = lint({}, check)[:criteria].first + assert_equal "skipped", result[:result] + assert_match(/uses a feature outside the portable subset of CRITERIA-FORMAT.md that dpplint does not evaluate \(lookahead\)/, result[:reason]) + end end diff --git a/test/services/resolve_check_test.rb b/test/services/resolve_check_test.rb index b78aba9..82d6bcd 100644 --- a/test/services/resolve_check_test.rb +++ b/test/services/resolve_check_test.rb @@ -132,6 +132,65 @@ def severities(check, responses) = messages(check, responses).map { |m| m[:sever assert_empty result end + # Order of evaluation within a request (dpp-criteria 4fcfe5c) + + VARY_ACCEPT = [{ "name" => "Vary", "contains" => "Accept" }].freeze + + test "wrong status: header fields are not evaluated" do + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => VARY_ACCEPT) } + assert_equal ["HTTP status is 404, expected 200"], violations(check, default: [404, "application/json", "{}", { "vary" => ["Origin"] }]) + end + + test "wrong content type: header fields are not evaluated" do + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => VARY_ACCEPT) } + assert_equal ["Content-Type is text/html, expected application/json"], + violations(check, default: [200, "text/html", "", { "vary" => ["Origin"] }]) + end + + test "wrong status and content type: both reported, header fields not evaluated" do + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => VARY_ACCEPT) } + assert_equal ["HTTP status is 500, expected 200", "Content-Type is text/html, expected application/json"], + violations(check, default: [500, "text/html", ""]) + end + + test "status and content type hold: header fields are evaluated" do + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => VARY_ACCEPT) } + assert_equal ['header Vary is "Origin", expected a member "Accept"'], + violations(check, default: [200, "application/json", passport, { "vary" => ["Origin"] }]) + end + + test "further request with wrong status or content type: its header fields are not evaluated" do + further = { "accept" => "text/html", "expect" => HTML.merge("headers" => VARY_ACCEPT) } + check = { "type" => "resolve", "expect" => JSON_EXPECT, "further_requests" => [further] } + assert_equal ["request with Accept text/html: HTTP status is 406, expected 200"], + violations(check, "text/html" => [406, "text/html", "", { "vary" => ["Origin"] }], default: [200, "application/json", passport]) + assert_equal ["request with Accept text/html: Content-Type is application/json, expected text/html"], + violations(check, "text/html" => [200, "application/json", passport, { "vary" => ["Origin"] }], default: [200, "application/json", passport]) + end + + test "further request with status and content type as expected: its header fields are evaluated" do + further = { "accept" => "text/html", "severity" => "warning", "expect" => HTML.merge("headers" => VARY_ACCEPT) } + check = { "type" => "resolve", "expect" => JSON_EXPECT, "further_requests" => [further] } + result = messages(check, "text/html" => [200, "text/html", "", { "vary" => ["Origin"] }], default: [200, "application/json", passport]) + assert_equal [{ severity: "warning", message: 'request with Accept text/html: header Vary is "Origin", expected a member "Accept"' }], result + end + + test "a failing first request does not stop the header fields of a further request" do + further = { "accept" => "text/html", "expect" => HTML.merge("headers" => VARY_ACCEPT) } + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => VARY_ACCEPT), "further_requests" => [further] } + result = violations(check, "text/html" => [200, "text/html", "", { "vary" => ["Origin"] }], default: [404, "application/json", "{}"]) + assert_equal ["HTTP status is 404, expected 200", 'request with Accept text/html: header Vary is "Origin", expected a member "Accept"'], result + end + + test "invalid or non-portable patterns are reported before any request" do + resolver = FakeResolver.new({}) + check = { "type" => "resolve", "expect" => JSON_EXPECT, + "further_requests" => [{ "accept" => "*/*", "expect" => { "headers" => [{ "name" => "Vary", "matches" => "a**" }] } }] } + assert_match(/\Aregular expression "a\*\*" for header Vary is not a valid ECMA-262 regular expression/, ResolveCheck.new(check, UPI, resolver).pattern_problem) + assert_nil ResolveCheck.new({ "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => [{ "name" => "Vary", "matches" => "^Accept" }]) }, UPI, resolver).pattern_problem + assert_empty resolver.requested + end + test "expect parts not evaluated for resolve are reported" do check = { "type" => "resolve", "expect" => { "status" => [200], "json" => [{ "path" => "$.a", "exists" => true }] }, "further_requests" => [{ "accept" => "*/*", "expect" => { "body_equals_step" => 1 } }] } @@ -139,7 +198,7 @@ def severities(check, responses) = messages(check, responses).map { |m| m[:sever assert_empty ResolveCheck.new({ "type" => "resolve", "expect" => HTML }, UPI, FakeResolver.new({})).unsupported end - # DPP-DAT-016 version 2 (dpp-criteria 4c155ee) + # DPP-DAT-016 version 2 (dpp-criteria 4c155ee, order of evaluation 4fcfe5c) DAT_016 = { "type" => "resolve", "accept" => "text/html", @@ -160,11 +219,16 @@ def severities(check, responses) = messages(check, responses).map { |m| m[:sever assert_equal %w[warning warning], result end - test "DPP-DAT-016: JSON on an HTML request is a violation" do + test "DPP-DAT-016: JSON on an HTML request is a violation, without a Vary warning about the JSON response" do result = messages(DAT_016, "text/html" => [200, "application/json; charset=utf-8", passport, { "vary" => ["Origin"] }], "*/*" => [200, "application/json", passport]) - assert_equal [["violation", "Content-Type is application/json; charset=utf-8, expected text/html"], - ["warning", 'header Vary is "Origin", expected a member "Accept"']], + assert_equal [["violation", "Content-Type is application/json; charset=utf-8, expected text/html"]], result.map { |m| [m[:severity], m[:message]] } end + + test "DPP-DAT-016: HTML without Vary Accept and JSON for */* gives one warning" do + result = messages(DAT_016, "text/html" => [200, "text/html", "", { "vary" => ["Origin"] }], + "*/*" => [200, "application/json", passport]) + assert_equal [["warning", 'header Vary is "Origin", expected a member "Accept"']], result.map { |m| [m[:severity], m[:message]] } + end end