diff --git a/README.md b/README.md index ea4a8fb..7b12515 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ 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 + 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 [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/header_assertion.rb b/app/services/header_assertion.rb new file mode 100644 index 0000000..70998fa --- /dev/null +++ b/app/services/header_assertion.rb @@ -0,0 +1,72 @@ +# Evaluates the header assertions of an `expect` block (expect.headers) on the +# header fields of a response, as defined in CRITERIA-FORMAT.md of dpp-criteria: +# +# - `name` is matched case-insensitively; several fields with the same name are +# combined into one value, separated by ", " (RFC 9110 5.3); +# - `exists`: the field is present (true) or absent (false); +# - `equals`: the combined value equals the string exactly; +# - `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; +# - a missing field fails every assertion except `exists: false`; +# - `severity: warning` reports a failure as a warning instead of a violation. +class HeaderAssertion + OPERATIONS = %w[exists equals contains matches].freeze + + # assertions: the list under expect.headers; headers: Hash of field name => + # value or list of values. Returns messages { severity:, message: }. + def self.messages(assertions, headers) + Array(assertions).flat_map do |assertion| + severity = assertion["severity"] == "warning" ? "warning" : "violation" + new(assertion).failures(headers).map { |m| { severity: severity, message: m } } + end + end + + # The combined value of all fields named `name`, or nil if there is none. + def self.field(headers, name) + values = (headers || {}).select { |key, _| key.to_s.casecmp?(name.to_s) }.values.flatten.map(&:to_s) + values.empty? ? nil : values.join(", ") + end + + def initialize(assertion) + @assertion = assertion + @name = assertion["name"].to_s + end + + # Returns one message per operation that does not hold; empty means it holds. + def failures(headers) + value = self.class.field(headers, @name) + OPERATIONS.select { |op| @assertion.key?(op) }.filter_map { |op| failure(op, @assertion[op], value) } + end + + private + + def failure(op, expected, value) + return exists(expected, value) if op == "exists" + return "header #{@name} is missing, expected #{describe(op, expected)}" if value.nil? + + 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) + 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) + return nil if expected == !value.nil? + + expected ? "header #{@name} is missing" : "header #{@name} is present (#{value.inspect}), expected it to be absent" + end + + def describe(op, expected) + case op + when "equals" then "exactly #{expected.to_s.inspect}" + when "contains" then "a member #{expected.to_s.inspect}" + when "matches" then "a value matching /#{expected}/" + end + end +end diff --git a/app/services/http_resolver.rb b/app/services/http_resolver.rb index d17337c..dda1f24 100644 --- a/app/services/http_resolver.rb +++ b/app/services/http_resolver.rb @@ -4,13 +4,15 @@ # Plain HTTP(S) GET like a phone scanning a data carrier: no credentials, # redirects followed, optional Accept header. Responses are cached per run. +# Header fields of the final response are kept as a Hash of lower-case field +# name => list of values (one entry per field line). # # Only public addresses are contacted: host names that resolve to loopback, # private, link-local or other internal ranges are refused (also after a # redirect), so that passports cannot make dpplint reach internal services. # DPPLINT_ALLOW_PRIVATE_NETWORKS=1 lifts this for local development. class HttpResolver - Response = Struct.new(:url, :status, :content_type, :body, :error, keyword_init: true) do + Response = Struct.new(:url, :status, :content_type, :body, :error, :headers, keyword_init: true) do def media_type = content_type.to_s.split(";").first.to_s.strip.downcase def success? = error.nil? && status.between?(200, 299) end @@ -80,7 +82,7 @@ def fetch(url, accept, mode) next end body = mode == :get ? res.body : nil - return Response.new(url: uri.to_s, status: res.code.to_i, content_type: res["content-type"], body: body) + return Response.new(url: uri.to_s, status: res.code.to_i, content_type: res["content-type"], body: body, headers: res.to_hash) end Response.new(url: uri.to_s, error: "more than #{MAX_REDIRECTS} redirects") rescue URI::InvalidURIError diff --git a/app/services/passport_linter.rb b/app/services/passport_linter.rb index ea9e25c..7cd5e5d 100644 --- a/app/services/passport_linter.rb +++ b/app/services/passport_linter.rb @@ -75,8 +75,13 @@ def resolve(base, check) return base.merge(result: "skipped", reason: "rated by dpp-validator from its daily runs") if check["history"] return base.merge(result: "skipped", reason: "needs a product identifier") if @product_id.blank? - messages = ResolveCheck.new(check, @product_id, @resolver).violations.map { |m| { severity: "violation", message: m } } - base.merge(result: messages.empty? ? "passed" : "failed", messages: messages) + resolve_check = ResolveCheck.new(check, @product_id, @resolver) + 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 + + messages = resolve_check.messages + base.merge(result: result_for(messages), messages: messages) end def did(base, check, passport) @@ -106,6 +111,8 @@ def links(base, check, passport) base.merge(result: result_for(messages), messages: messages) end + # failed if a check with severity error (a violation) fails, otherwise + # warning if a check with severity warning fails, otherwise passed. def result_for(messages) if messages.any? { |m| m[:severity] == "violation" } then "failed" elsif messages.any? then "warning" diff --git a/app/services/resolve_check.rb b/app/services/resolve_check.rb index 8187b74..9727c19 100644 --- a/app/services/resolve_check.rb +++ b/app/services/resolve_check.rb @@ -1,54 +1,83 @@ # Runs a criterion with check.type resolve against a product identifier. # -# Without `expect`, the identifier 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. +# 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. +# +# `further_requests` are sent after the first request to the same identifier, +# each with its own `accept` and `expect`, and are evaluated independently of +# it. `severity: warning` on a further request reports every failure of that +# request as a warning. class ResolveCheck + # Parts of `expect` this version evaluates for check type resolve. + SUPPORTED_EXPECT = %w[status content_type headers].freeze + def initialize(check, product_id, resolver) @check = check @product_id = product_id @resolver = resolver end - # Returns a list of violation messages; empty means passed. - def violations - res = @resolver.get(@product_id, accept: @check["accept"]) - return [res.error] if res.error + # 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 + end - @check["expect"] ? expected(res) : resolves_to_passport(res) + # Returns messages { severity: "violation" | "warning", message: }; none means passed. + def messages + first = request(@check["accept"], @check["expect"]) + further = Array(@check["further_requests"]).flat_map do |r| + request(r["accept"], r["expect"] || {}, severity: r["severity"], label: "request with Accept #{r['accept']}") + end + first + further end private - def expected(res) - expect = @check["expect"] + def request(accept, expect, severity: nil, label: nil) + res = @resolver.get(@product_id, accept: accept) + out = if res.error then [violation(res.error)] + elsif expect then expected(res, expect) + else resolves_to_passport(res) + end + out = out.map { |m| m.merge(severity: "warning") } if severity == "warning" + label ? out.map { |m| m.merge(message: "#{label}: #{m[:message]}") } : out + end + + def expected(res, expect) out = [] if expect["status"] && !expect["status"].include?(res.status) - out << "HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}" + out << violation("HTTP status is #{res.status}, expected #{expect['status'].join(' or ')}") end if (type = expect["content_type"]) if res.media_type != type.downcase - out << "Content-Type is #{res.content_type.presence || 'missing'}, expected #{type}" + out << violation("Content-Type is #{res.content_type.presence || 'missing'}, expected #{type}") elsif type.downcase == "application/json" && !json_object?(res.body) - out << "response is not a single JSON object" + out << violation("response is not a single JSON object") end end - out + out + HeaderAssertion.messages(expect["headers"], res.headers) end def resolves_to_passport(res) - return ["HTTP status is #{res.status}, expected 2xx"] unless res.success? + return [violation("HTTP status is #{res.status}, expected 2xx")] unless res.success? json = JSON.parse(res.body) rescue nil - return ["response is not a single JSON object"] unless json.is_a?(Hash) + return [violation("response is not a single JSON object")] unless json.is_a?(Hash) upi = json["uniqueProductIdentifier"] return [] if upi == @product_id - ["uniqueProductIdentifier is #{upi.inspect}, expected the requested identifier #{@product_id}"] + [violation("uniqueProductIdentifier is #{upi.inspect}, expected the requested identifier #{@product_id}")] end + def violation(message) = { severity: "violation", message: message } + def json_object?(body) JSON.parse(body).is_a?(Hash) rescue JSON::ParserError diff --git a/test/services/header_assertion_test.rb b/test/services/header_assertion_test.rb new file mode 100644 index 0000000..bd837ef --- /dev/null +++ b/test/services/header_assertion_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class HeaderAssertionTest < ActiveSupport::TestCase + def problems(assertion, headers) = HeaderAssertion.new(assertion).failures(headers) + def holds?(assertion, headers) = problems(assertion, headers).empty? + + VARY = { "vary" => ["Accept-Encoding, Accept"] }.freeze + + test "exists true holds when the field is present, fails when it is missing" do + assert holds?({ "name" => "Vary", "exists" => true }, VARY) + assert_equal ["header Vary is missing"], problems({ "name" => "Vary", "exists" => true }, {}) + end + + test "exists false holds when the field is missing, fails when it is present" do + assert holds?({ "name" => "Vary", "exists" => false }, {}) + assert_match(/header Vary is present .*expected it to be absent/, problems({ "name" => "Vary", "exists" => false }, VARY).first) + end + + test "field present with an empty value exists" do + assert holds?({ "name" => "Vary", "exists" => true }, { "vary" => [""] }) + end + + test "equals compares the whole value exactly" do + assert holds?({ "name" => "Vary", "equals" => "Accept-Encoding, Accept" }, VARY) + refute holds?({ "name" => "Vary", "equals" => "Accept" }, VARY) + refute holds?({ "name" => "Vary", "equals" => "accept-encoding, accept" }, VARY) + end + + test "contains finds a trimmed member, case-insensitively" do + assert holds?({ "name" => "Vary", "contains" => "Accept" }, VARY) + assert holds?({ "name" => "Vary", "contains" => "accept" }, { "vary" => ["Origin , ACCEPT "] }) + assert_equal ['header Vary is "Accept-Encoding", expected a member "Accept"'], + problems({ "name" => "Vary", "contains" => "Accept" }, { "vary" => ["Accept-Encoding"] }) + end + + test "Vary * does not contain Accept" do + 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"] }) + 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) + end + + test "missing field fails equals, contains and matches" do + %w[equals contains matches].each do |op| + assert_match(/\Aheader Vary is missing, expected/, problems({ "name" => "Vary", op => "Accept" }, {}).first, op) + end + end + + test "missing field fails every operation of an assertion except exists false" do + result = problems({ "name" => "Vary", "exists" => false, "contains" => "Accept" }, {}) + assert_equal ['header Vary is missing, expected a member "Accept"'], result + end + + test "field name is matched case-insensitively" do + assert holds?({ "name" => "vary", "contains" => "Accept" }, { "Vary" => ["Accept"] }) + assert holds?({ "name" => "VARY", "equals" => "Accept" }, { "vary" => ["Accept"] }) + end + + test "several fields with the same name are combined with commas" do + headers = { "vary" => ["Origin", "Accept"] } + assert holds?({ "name" => "Vary", "equals" => "Origin, Accept" }, headers) + assert holds?({ "name" => "Vary", "contains" => "Accept" }, headers) + assert holds?({ "name" => "Vary", "matches" => "Origin, Accept" }, headers) + end + + test "fields whose names differ only in case are combined" do + headers = { "Vary" => "Origin", "vary" => ["Accept"] } + assert_equal "Origin, Accept", HeaderAssertion.field(headers, "VARY") + end + + test "all operations of one assertion are checked" do + result = problems({ "name" => "Vary", "exists" => true, "equals" => "Accept", "contains" => "Origin" }, { "vary" => ["Accept"] }) + assert_equal ['header Vary is "Accept", expected a member "Origin"'], result + end + + test "severity warning turns a failure into a warning" do + assertions = [{ "name" => "Vary", "contains" => "Accept", "severity" => "warning" }, { "name" => "Vary", "exists" => true }] + assert_equal [{ severity: "warning", message: "header Vary is missing, expected a member \"Accept\"" }, + { severity: "violation", message: "header Vary is missing" }], + HeaderAssertion.messages(assertions, {}) + end + + test "severity error is a violation" do + assert_equal ["violation"], HeaderAssertion.messages([{ "name" => "Vary", "exists" => true, "severity" => "error" }], nil).map { |m| m[:severity] } + end +end diff --git a/test/services/passport_linter_test.rb b/test/services/passport_linter_test.rb new file mode 100644 index 0000000..76a1676 --- /dev/null +++ b/test/services/passport_linter_test.rb @@ -0,0 +1,74 @@ +require "test_helper" + +# Overall result of a resolve criterion: failed if a check with severity error +# fails, otherwise warning if a check with severity warning fails, otherwise passed. +class PassportLinterTest < ActiveSupport::TestCase + UPI = "https://dpp.example.org/01/09520123456788/21/0001".freeze + + class FakeCatalogue + def initialize(criteria) = @criteria = criteria + def passport_criteria = @criteria + end + + class FakeResolver + def initialize(responses) = @responses = responses + + def get(_url, accept: nil) + status, type, body, headers = @responses.fetch(accept) + HttpResolver::Response.new(url: UPI, status: status, content_type: type, body: body, headers: headers || {}) + end + end + + CHECK = { + "type" => "resolve", "accept" => "text/html", + "expect" => { "status" => [200], "content_type" => "text/html", + "headers" => [{ "name" => "Vary", "contains" => "Accept", "severity" => "warning" }] }, + "further_requests" => [{ "accept" => "*/*", "severity" => "warning", + "expect" => { "status" => [200], "content_type" => "application/json" } }] + }.freeze + + def criterion(check = CHECK) = { "id" => "DPP-DAT-016", "title" => "t", "level" => "MUST", "target" => "passport", "method" => "automated", "check" => check } + + def lint(responses, check = CHECK) + PassportLinter.new(catalogue: FakeCatalogue.new([criterion(check)]), resolver: FakeResolver.new(responses)) + .run(passport: nil, product_id: UPI) + end + + HTML = [200, "text/html", "", { "vary" => ["Accept"] }].freeze + JSON_OK = [200, "application/json", '{"uniqueProductIdentifier":"x"}'].freeze + + test "no failing check passes" do + report = lint("text/html" => HTML, "*/*" => JSON_OK) + assert_equal "passed", report[:criteria].first[:result] + assert_equal "1 of 1 automated checks passed", report[:summary][:text] + end + + test "failing warning checks give a warning, which still counts as passed" do + report = lint("text/html" => [200, "text/html", "", { "vary" => ["Origin"] }], "*/*" => [200, "text/html", ""]) + result = report[:criteria].first + assert_equal "warning", result[:result] + assert_equal %w[warning warning], result[:messages].map { |m| m[:severity] } + assert_equal({ passed: 1, failed: 0, warnings: 1 }, report[:summary].slice(:passed, :failed, :warnings)) + 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) + result = report[:criteria].first + assert_equal "failed", result[:result] + assert_equal %w[violation warning], result[:messages].map { |m| m[:severity] } + assert_equal "0 of 1 automated checks passed", report[:summary][:text] + end + + test "a further request without severity fails the criterion" do + check = CHECK.merge("further_requests" => [{ "accept" => "*/*", "expect" => { "status" => [200], "content_type" => "application/json" } }]) + report = lint({ "text/html" => HTML, "*/*" => [200, "text/html", ""] }, check) + assert_equal "failed", report[:criteria].first[:result] + end + + test "expect parts not evaluated for resolve skip the criterion" do + check = { "type" => "resolve", "expect" => { "status" => [200], "json" => [{ "path" => "$.a", "exists" => true }] } } + result = lint({}, check)[:criteria].first + assert_equal "skipped", result[:result] + assert_equal "expect json is not evaluated for check type resolve in this version", result[:reason] + end +end diff --git a/test/services/resolve_check_test.rb b/test/services/resolve_check_test.rb index 161539c..b78aba9 100644 --- a/test/services/resolve_check_test.rb +++ b/test/services/resolve_check_test.rb @@ -3,19 +3,28 @@ class ResolveCheckTest < ActiveSupport::TestCase UPI = "https://dpp.example.org/01/09520123456788/21/0001".freeze - # Answers GET requests from a table instead of the network. + # Answers GET requests from a table of Accept header => [status, content type, + # body, header fields] instead of the network, and records the Accept headers + # requested. A value of :error stands for a failed retrieval. class FakeResolver - def initialize(responses) = @responses = responses + attr_reader :requested + + def initialize(responses) = (@responses, @requested = responses, []) def get(_url, accept: nil) - status, type, body = @responses.fetch(accept) { @responses.fetch(:default) } - HttpResolver::Response.new(url: UPI, status: status, content_type: type, body: body) + @requested << accept + status, type, body, headers = @responses.fetch(accept) { @responses.fetch(:default) } + return HttpResolver::Response.new(url: UPI, error: "retrieval failed: timeout (test)") if status == :error + + HttpResolver::Response.new(url: UPI, status: status, content_type: type, body: body, headers: headers || {}) end end def passport(upi = UPI) = { "uniqueProductIdentifier" => upi }.to_json - def violations(check, responses) = ResolveCheck.new(check, UPI, FakeResolver.new(responses)).violations + def messages(check, responses) = ResolveCheck.new(check, UPI, FakeResolver.new(responses)).messages + def violations(check, responses) = messages(check, responses).map { |m| m[:message] } + def severities(check, responses) = messages(check, responses).map { |m| m[:severity] } test "identifier resolves to its passport" do assert_empty violations({ "type" => "resolve" }, default: [200, "application/json", passport]) @@ -54,4 +63,108 @@ def violations(check, responses) = ResolveCheck.new(check, UPI, FakeResolver.new check = { "type" => "resolve", "expect" => { "status" => [200] } } assert_match(/HTTP status is 401/, violations(check, default: [401, "application/json", "{}"]).first) end + + # further_requests + + HTML = { "status" => [200], "content_type" => "text/html" }.freeze + JSON_EXPECT = { "status" => [200], "content_type" => "application/json" }.freeze + + test "further request is sent with its own Accept header after the first request" do + check = { "type" => "resolve", "accept" => "text/html", "expect" => HTML, + "further_requests" => [{ "accept" => "*/*", "expect" => JSON_EXPECT }] } + resolver = FakeResolver.new("text/html" => [200, "text/html", ""], "*/*" => [200, "application/json", passport]) + assert_empty ResolveCheck.new(check, UPI, resolver).messages + assert_equal ["text/html", "*/*"], resolver.requested + end + + test "failing further request without severity is a violation" do + check = { "type" => "resolve", "accept" => "text/html", "expect" => HTML, + "further_requests" => [{ "accept" => "*/*", "expect" => JSON_EXPECT }] } + result = messages(check, "text/html" => [200, "text/html", ""], "*/*" => [200, "text/html", ""]) + assert_equal [{ severity: "violation", message: "request with Accept */*: Content-Type is text/html, expected application/json" }], result + end + + test "failing further request with severity warning is a warning" do + check = { "type" => "resolve", "accept" => "text/html", "expect" => HTML, + "further_requests" => [{ "accept" => "*/*", "severity" => "warning", "expect" => JSON_EXPECT }] } + result = messages(check, "text/html" => [200, "text/html", ""], "*/*" => [404, "text/html", ""]) + assert_equal %w[warning warning], result.map { |m| m[:severity] } + assert_match(/\Arequest with Accept \*\/\*: HTTP status is 404/, result.first[:message]) + end + + test "severity warning of a further request also covers a failed retrieval" do + check = { "type" => "resolve", "expect" => JSON_EXPECT, + "further_requests" => [{ "accept" => "text/html", "severity" => "warning", "expect" => HTML }] } + result = messages(check, "text/html" => [:error], default: [200, "application/json", passport]) + assert_equal [{ severity: "warning", message: "request with Accept text/html: retrieval failed: timeout (test)" }], result + end + + test "further requests are evaluated independently of a failing first request" do + check = { "type" => "resolve", "accept" => "text/html", "expect" => HTML, + "further_requests" => [{ "accept" => "*/*", "severity" => "warning", "expect" => JSON_EXPECT }, + { "accept" => "application/json", "expect" => JSON_EXPECT }] } + result = messages(check, "text/html" => [:error], "*/*" => [404, "application/json", "{}"], "application/json" => [200, "application/json", passport]) + assert_equal [{ severity: "violation", message: "retrieval failed: timeout (test)" }, + { severity: "warning", message: "request with Accept */*: HTTP status is 404, expected 200" }], result + end + + test "further request passes while the first request fails" do + check = { "type" => "resolve", "accept" => "text/html", "expect" => HTML, + "further_requests" => [{ "accept" => "*/*", "severity" => "warning", "expect" => JSON_EXPECT }] } + result = messages(check, "text/html" => [200, "application/json", passport], "*/*" => [200, "application/json", passport]) + assert_equal ["violation"], result.map { |m| m[:severity] } + end + + # expect.headers within resolve + + test "header assertion with severity warning gives a warning, without severity a violation" do + headers = [{ "name" => "Vary", "contains" => "Accept", "severity" => "warning" }, { "name" => "Cache-Control", "exists" => true }] + check = { "type" => "resolve", "expect" => JSON_EXPECT.merge("headers" => headers) } + result = messages(check, default: [200, "application/json", passport, { "vary" => ["Origin"] }]) + assert_equal [["warning", 'header Vary is "Origin", expected a member "Accept"'], ["violation", "header Cache-Control is missing"]], + result.map { |m| [m[:severity], m[:message]] } + end + + test "header assertions of a further request are checked on its own response" do + check = { "type" => "resolve", "expect" => JSON_EXPECT, + "further_requests" => [{ "accept" => "text/html", "expect" => HTML.merge("headers" => [{ "name" => "Vary", "contains" => "accept" }]) }] } + result = messages(check, default: [200, "application/json", passport], "text/html" => [200, "text/html", "", { "Vary" => ["Origin, Accept"] }]) + assert_empty result + 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 } }] } + assert_equal %w[json body_equals_step], ResolveCheck.new(check, UPI, FakeResolver.new({})).unsupported + assert_empty ResolveCheck.new({ "type" => "resolve", "expect" => HTML }, UPI, FakeResolver.new({})).unsupported + end + + # DPP-DAT-016 version 2 (dpp-criteria 4c155ee) + + DAT_016 = { + "type" => "resolve", "accept" => "text/html", + "expect" => { "status" => [200], "content_type" => "text/html", + "headers" => [{ "name" => "Vary", "contains" => "Accept", "severity" => "warning" }] }, + "further_requests" => [{ "accept" => "*/*", "severity" => "warning", + "expect" => { "status" => [200], "content_type" => "application/json" } }] + }.freeze + + test "DPP-DAT-016: HTML with Vary Accept and JSON for */* passes" do + assert_empty messages(DAT_016, "text/html" => [200, "text/html; charset=utf-8", "", { "vary" => ["Accept, Origin"] }], + "*/*" => [200, "application/json", passport]) + end + + test "DPP-DAT-016: HTML without Vary Accept and HTML for */* gives two warnings" do + result = severities(DAT_016, "text/html" => [200, "text/html", "", { "vary" => ["*"] }], + "*/*" => [200, "text/html", ""]) + assert_equal %w[warning warning], result + end + + test "DPP-DAT-016: JSON on an HTML request is a violation" 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"']], + result.map { |m| [m[:severity], m[:message]] } + end end