diff --git a/README.md b/README.md index 3d0a872..e9eee9c 100644 --- a/README.md +++ b/README.md @@ -103,14 +103,33 @@ complete and are not a legal assessment. - Criteria with `check.type: links` check every `RelatedResource` of the passport for the required attributes and send a HEAD request to its URL (at most 20 URLs per passport). A URL that does not answer gives a warning. -- Criteria with `check.type: html` (DPP-SEC-014) request the product - identifier with `Accept: text/html` and list the URLs a browser loads without - user action while it displays the HTML view (scripts, style sheets, fonts, - images, frames, preloads, CSS `url()` and `@import`, meta refresh). With - `external_resources: forbidden` every such URL on another host than the - passport's fails the criterion. Without an HTML view the result is - `skipped` (`no_evidence`). Resources added by scripts at run time are not - seen. +- Criteria with `check.type: html` (DPP-SEC-014, DPP-SEC-021) request the + product identifier with `Accept: text/html`. Without an HTML view the result + is `skipped` (`no_evidence`). + - With `external_resources: forbidden` dpplint lists the URLs a browser loads + without user action while it displays the HTML view (scripts, style + sheets, fonts, images, frames, preloads, CSS `url()` and `@import`, meta + refresh); every such URL on another host than the passport's fails the + criterion. Resources added by scripts at run time are not seen. + - With `device_features: forbidden` dpplint searches, without running any + script, the inline scripts, event handler attributes and `javascript:` + URLs of the HTML view and the scripts it loads from its own host (at most + 10, the first 2 MB of each) for browser interfaces that use functions of + the reader's device (location, camera, microphone, notifications, + contacts, sensors, network information and others), and the markup for + frames granted such permissions, file inputs with `capture` and permission + elements. Every finding fails the criterion. Code assembled at run time, + obfuscated or loaded by a script, and scripts from other hosts, are not + seen; an interface name in a comment or string counts. +- Criteria with `check.type: element_ids` (DPP-DAT-017, DPP-DAT-021) check + that elementIds are unique. In the expanded full serialization dpplint + compares the elementIds within every array of data elements (`unique: + level`, a repeated elementId fails) or across the whole passport (`unique: + passport`, an elementId at two places gives a warning; the items of a + MultiValuedDataElement count as one place). In the compressed serialization + `level` looks for a member name repeated within one JSON object of the + passport as delivered (the response, or the posted body), and `passport` is + `skipped`. - Criteria whose condition does not hold are reported as `skipped`. - Every `skipped` criterion carries a `reason` and a `reason_code` (`not_applicable`, `no_evidence`, `not_evaluated`, … as defined under diff --git a/app/controllers/api/v1/validations_controller.rb b/app/controllers/api/v1/validations_controller.rb index b17855b..3b609a1 100644 --- a/app/controllers/api/v1/validations_controller.rb +++ b/app/controllers/api/v1/validations_controller.rb @@ -24,7 +24,7 @@ def create passport = JSON.parse(request.raw_post) return render(json: { error: "body is not a JSON object" }, status: :unprocessable_entity) unless passport.is_a?(Hash) - render json: PassportLinter.new.run(passport: passport) + render json: PassportLinter.new.run(passport: passport, source: request.raw_post) rescue JSON::ParserError => e render json: { error: "invalid JSON: #{e.message}" }, status: :unprocessable_entity end diff --git a/app/services/element_ids_check.rb b/app/services/element_ids_check.rb new file mode 100644 index 0000000..9352779 --- /dev/null +++ b/app/services/element_ids_check.rb @@ -0,0 +1,137 @@ +# Runs a criterion with check.type element_ids: whether the elementIds of the +# passport are unique (EN 18223 4.1.2.3, Table 2; "Check types", +# element_ids, in CRITERIA-FORMAT.md of dpp-criteria). +# +# In the expanded full serialization (EN 18223 Annex A; an `elements` array +# at the top level) the data elements are the objects with an elementId in +# the `elements` arrays of the passport, of its DataElementCollections and +# MultiValuedDataElements, and in the `value` array of a MultiValuedDataElement +# whose items are such objects. +# +# unique: level - every elementId once per array of data elements (a +# violation for each one repeated). In the compressed serialization the +# elementIds are JSON member names, so no JSON object of the passport as +# delivered (`source`, the bytes of the response or of the posted body) may +# contain a name twice; without those bytes the criterion is skipped. +# unique: passport - every elementId at one place of the passport (a warning +# for each one used at several places). The items of a MultiValuedDataElement +# count as one place, because they repeat one structure. Only the expanded +# full serialization. +class ElementIdsCheck + Outcome = Struct.new(:skipped, :code, :messages, keyword_init: true) + + Element = Struct.new(:id, :place, keyword_init: true) + + MAX_LISTED = 10 + + def initialize(check, passport, source: nil) + @check = check + @passport = passport + @source = source + end + + def outcome + if full? + @check["unique"] == "passport" ? Outcome.new(messages: passport_messages) : Outcome.new(messages: level_messages) + elsif @check["unique"] == "passport" + Outcome.new(skipped: "the passport is in the compressed serialization, where elementIds cannot be told apart from " \ + "attribute names without the data dictionary", code: "not_evaluated") + elsif @source.nil? + Outcome.new(skipped: "the passport is in the compressed serialization and is not available as delivered, so " \ + "repeated member names cannot be seen", code: "not_evaluated") + else + compressed_level_outcome + end + end + + # The full serialization has an `elements` array at the top level. + def full? = @passport.is_a?(Hash) && @passport["elements"].is_a?(Array) + + private + + def level_messages + levels = [] + walk(@passport["elements"], [], [], levels: levels) + repeated = levels.flat_map do |parent, ids| + ids.tally.select { |_, n| n > 1 }.map { |id, n| "#{id} #{n} times #{where(parent)}" } + end + return [] if repeated.empty? + + [{ severity: "violation", message: "elementId repeated within one level: #{listed(repeated)}" }] + end + + def passport_messages + elements = [] + walk(@passport["elements"], [], [], elements: elements) + places = elements.group_by(&:id).transform_values { |list| list.map(&:place).uniq } + shared = places.select { |_, list| list.size > 1 } + return [] if shared.empty? + + texts = shared.map { |id, list| "#{id} at #{list.size} places (#{list.map { |p| p.join('/') }.join(', ')})" } + [{ severity: "warning", message: "elementId used at more than one place of the passport: #{listed(texts)}" }] + end + + # Walks one array of data elements. path: the elementIds above it; place: + # the same with "*" for the items of a MultiValuedDataElement. + def walk(array, path, place, levels: nil, elements: nil) + items = array.select { |e| e.is_a?(Hash) && e["elementId"].is_a?(String) } + levels&.push([path, items.map { |e| e["elementId"] }]) + items.each do |e| + id = e["elementId"] + elements&.push(Element.new(id: id, place: place + [id])) + multi = e["objectType"] == "MultiValuedDataElement" + children(e).each do |list| + if multi + walk_items(list, path + [id], place + [id], levels: levels, elements: elements) + else + walk(list, path + [id], place + [id], levels: levels, elements: elements) + end + end + end + end + + # The items of a MultiValuedDataElement: one level for the repeated-id check; + # below them, every item counts as the same place ("*"). + def walk_items(array, path, place, levels:, elements:) + items = array.select { |e| e.is_a?(Hash) && e["elementId"].is_a?(String) } + levels&.push([path, items.map { |e| e["elementId"] }]) + items.each do |e| + id = e["elementId"] + elements&.push(Element.new(id: id, place: place + ["*"])) + multi = e["objectType"] == "MultiValuedDataElement" + children(e).each do |list| + if multi + walk_items(list, path + [id], place + ["*"], levels: levels, elements: elements) + else + walk(list, path + [id], place + ["*"], levels: levels, elements: elements) + end + end + end + end + + # Arrays of data elements below an element: `elements`, and `value` if it + # holds objects with an elementId (MultiValuedDataElement items, Annex A). + def children(element) + %w[elements value].filter_map do |key| + list = element[key] + list if list.is_a?(Array) && list.any? { |i| i.is_a?(Hash) && i["elementId"].is_a?(String) } + end + end + + def compressed_level_outcome + duplicates = JsonDuplicates.find(@source) + return Outcome.new(messages: []) if duplicates.empty? + + texts = duplicates.map { |d| "#{d[:name]} #{d[:count]} times in the object at #{d[:path]}" } + Outcome.new(messages: [{ severity: "violation", message: "member name repeated within one JSON object: #{listed(texts)}" }]) + rescue JsonDuplicates::Invalid => e + Outcome.new(skipped: "the passport as delivered cannot be read: #{e.message}", code: "not_evaluated") + end + + def where(parent) = parent.empty? ? "at the top level of the passport" : "in #{parent.join('/')}" + + def listed(texts) + more = texts.size > MAX_LISTED ? " and #{texts.size - MAX_LISTED} more" : "" + "#{texts.first(MAX_LISTED).join('; ')}#{more}" + end +end diff --git a/app/services/html_check.rb b/app/services/html_check.rb index 9d0c5c0..aaf00bb 100644 --- a/app/services/html_check.rb +++ b/app/services/html_check.rb @@ -12,9 +12,39 @@ # differs from the host of the final response URL. Links the user has to # follow are not counted; resources added by scripts at run time cannot be # seen in the document. +# +# device_features: forbidden looks, without running any script, for browser +# interfaces that use functions of the reader's device (DEVICE_INTERFACES) in +# the inline scripts, event handler attributes and javascript: URLs of the +# document and in the scripts it loads from its own host (at most MAX_SCRIPTS, +# the first MAX_SCRIPT_BYTES of each; a script that cannot be retrieved is not +# checked), and in the markup for frames granted device permissions +# (DEVICE_PERMISSIONS), file inputs with capture and permission elements. class HtmlCheck Outcome = Struct.new(:skipped, :code, :messages, keyword_init: true) + DEVICE_INTERFACES = [ + ["location", /\.geolocation\b|\bgetCurrentPosition\b|\bwatchPosition\b/], + ["camera, microphone or screen", /\bgetUserMedia\b|\bgetDisplayMedia\b|\.mediaDevices\b/], + ["notifications", /\bNotification\s*\.\s*requestPermission\b|\bnew\s+Notification\s*\(|\.showNotification\s*\(|\b[Pp]ushManager\b/], + ["contacts", /\bnavigator\s*\.\s*contacts\b/], + ["Bluetooth, USB, serial, HID or NFC", /\bnavigator\s*\.\s*(?:bluetooth|usb|serial|hid)\b|\bNDEFReader\b/], + ["motion or orientation sensors", + /\b(?:on)?(?:devicemotion|deviceorientation(?:absolute)?)\b|\bDevice(?:Motion|Orientation)Event\s*\.\s*requestPermission\b|\bnew\s+(?:Accelerometer|Gyroscope|Magnetometer|AbsoluteOrientationSensor|RelativeOrientationSensor|LinearAccelerationSensor|GravitySensor|AmbientLightSensor)\s*\(/], + ["context or network information", /\bnavigator\s*\.\s*connection\b|\bgetBattery\s*\(|\bIdleDetector\b/], + ["one-time codes from SMS", /\bOTPCredential\b|\btransport\s*:\s*\[\s*["']sms["']/], + ["app installation", /\bbeforeinstallprompt\b|\bgetInstalledRelatedApps\b/], + ["device behaviour", /\bnavigator\s*\.\s*(?:wakeLock|vibrate)\b/] + ].freeze + DEVICE_PERMISSIONS = %w[camera microphone geolocation display-capture bluetooth usb serial hid accelerometer gyroscope + magnetometer ambient-light-sensor idle-detection screen-wake-lock].freeze + JS_TYPES = %w[text/javascript application/javascript application/ecmascript text/ecmascript module + application/x-javascript text/jscript text/x-javascript].freeze + URL_ATTRIBUTES = %w[href src action formaction].freeze + MAX_SCRIPTS = 10 + MAX_SCRIPT_BYTES = 2 * 1024 * 1024 + MAX_FINDINGS = 10 + SRC_ELEMENTS = %w[script img iframe frame embed audio video source track].freeze SRCSET_ELEMENTS = %w[img source].freeze LINK_RELS = %w[stylesheet icon apple-touch-icon mask-icon manifest preload modulepreload @@ -37,9 +67,23 @@ def outcome messages = [] messages << external_message(res) if @check["external_resources"] == "forbidden" + messages << device_message(res) if @check["device_features"] == "forbidden" Outcome.new(messages: messages.compact) end + # Device interfaces found in a piece of script: [[label, matched text], …]. + def self.device_uses(code) + DEVICE_INTERFACES.filter_map do |label, pattern| + match = code.to_s.match(pattern) + [label, match[0].gsub(/\s+/, " ")] if match + end + end + + def self.javascript_type?(type) + type = type.to_s.split(";").first.to_s.strip.downcase + type.empty? || JS_TYPES.include?(type) + end + # The URLs a browser loads without user action, resolved, with the element # or construct they come from: [[url, source], …]. def self.loaded_urls(html, page_url) @@ -121,6 +165,61 @@ def external_message(res) message: "the HTML view loads #{external.size} resource(s) from other hosts than #{own}: #{listed.join(', ')}#{more}" } end + def device_message(res) + doc = Nokogiri::HTML5(res.body.to_s) + findings = markup_findings(doc) + inline_findings(doc) + script_findings(doc, res.url.to_s) + return nil if findings.empty? + + listed = findings.first(MAX_FINDINGS) + more = findings.size > MAX_FINDINGS ? " and #{findings.size - MAX_FINDINGS} more" : "" + { severity: "violation", message: "the HTML view uses functions of the reader's device: #{listed.join('; ')}#{more}" } + end + + def markup_findings(doc) + found = [] + doc.css("iframe[allow]").each do |e| + granted = e["allow"].to_s.split(";").map { |d| d.strip.split(/\s+/).first.to_s.downcase } & DEVICE_PERMISSIONS + found << "iframe allow grants #{granted.join(', ')}" if granted.any? + end + found << "file input with capture" if doc.css("input[capture]").any? + %w[permission geolocation].each { |name| found << "#{name} element" if doc.css(name).any? } + found + end + + def inline_findings(doc) + found = [] + doc.css("script").each_with_index do |e, i| + next if e["src"] || !self.class.javascript_type?(e["type"]) + + self.class.device_uses(e.content).each { |label, use| found << "#{label} (#{use} in inline script #{i + 1})" } + end + doc.xpath("//@*").each do |a| + name = a.name.downcase + code = if name.start_with?("on") then a.value + elsif URL_ATTRIBUTES.include?(name) && a.value.to_s.strip.downcase.start_with?("javascript:") then a.value + end + next unless code + + self.class.device_uses(code).each { |label, use| found << "#{label} (#{use} in attribute #{name} of #{a.parent.name})" } + end + found + end + + # Scripts loaded from the host of the page, fetched as text and searched. + def script_findings(doc, page_url) + own = self.class.web_host(page_url) + base = self.class.absolute(doc.at_css("base[href]")&.[]("href"), page_url) || page_url + urls = doc.css("script[src]").select { |e| self.class.javascript_type?(e["type"]) }.map { |e| e["src"] } + urls += doc.css("link[href]").select { |e| e["rel"].to_s.downcase.split.include?("modulepreload") }.map { |e| e["href"] } + urls = urls.filter_map { |u| self.class.absolute(u, base) }.uniq.select { |u| self.class.web_host(u) == own } + urls.first(MAX_SCRIPTS).flat_map do |url| + res = @resolver.get(url, accept: "*/*") + next [] unless res.success? + + self.class.device_uses(res.body.to_s.byteslice(0, MAX_SCRIPT_BYTES).to_s.dup.force_encoding(Encoding::UTF_8).scrub).map { |label, use| "#{label} (#{use} in #{url})" } + end + end + def describe(res) return res.error if res.error diff --git a/app/services/json_duplicates.rb b/app/services/json_duplicates.rb new file mode 100644 index 0000000..5c922d5 --- /dev/null +++ b/app/services/json_duplicates.rb @@ -0,0 +1,105 @@ +require "strscan" + +# Finds member names that occur more than once in one JSON object, in JSON +# text as it was delivered. JSON parsers keep only one of the values of such a +# name, so the repetition cannot be seen in the parsed passport. +# +# JsonDuplicates.find('{"a":1,"b":{"c":1,"c":2}}') +# # => [{ path: "$['b']", name: "c", count: 2 }] +# +# Names are compared after their escapes are resolved ("a" and "a" are +# the same name). Paths are normalized JSONPaths (RFC 9535 2.7). Raises +# JsonDuplicates::Invalid if the text is not a single JSON value or nests +# deeper than MAX_DEPTH. +class JsonDuplicates + class Invalid < StandardError; end + + MAX_DEPTH = 512 + WS = /[ \t\n\r]*/ + STRING = /"(?:[^"\\\u0000-\u001f]|\\(?:["\\\/bfnrt]|u\h{4}))*"/ + NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/ + LITERAL = /true|false|null/ + + def self.find(text) + new(text).find + end + + def initialize(text) + source = text.to_s.dup.force_encoding(Encoding::UTF_8) + raise Invalid, "the text is not valid UTF-8" unless source.valid_encoding? + + @s = StringScanner.new(source.delete_prefix("")) + @found = [] + end + + def find + @s.skip(WS) + value("$", 0) + @s.skip(WS) + raise Invalid, "unexpected text after the JSON value at character #{@s.charpos}" unless @s.eos? + + @found + end + + private + + def value(path, depth) + raise Invalid, "nested deeper than #{MAX_DEPTH} levels" if depth > MAX_DEPTH + + if @s.skip(/\{/) then object(path, depth) + elsif @s.skip(/\[/) then array(path, depth) + elsif @s.scan(STRING) || @s.scan(NUMBER) || @s.scan(LITERAL) then nil + else raise Invalid, "unexpected character at #{@s.charpos}" + end + end + + def object(path, depth) + counts = Hash.new(0) + @s.skip(WS) + unless @s.skip(/\}/) + loop do + @s.skip(WS) + token = @s.scan(STRING) or raise Invalid, "member name expected at character #{@s.charpos}" + name = JSON.parse(token) + counts[name] += 1 + @s.skip(WS) + @s.skip(/:/) or raise Invalid, "':' expected at character #{@s.charpos}" + @s.skip(WS) + value("#{path}[#{self.class.quote(name)}]", depth + 1) + @s.skip(WS) + next if @s.skip(/,/) + break if @s.skip(/\}/) + + raise Invalid, "',' or '}' expected at character #{@s.charpos}" + end + end + counts.each { |name, count| @found << { path: path, name: name, count: count } if count > 1 } + end + + def array(path, depth) + @s.skip(WS) + return if @s.skip(/\]/) + + index = 0 + loop do + @s.skip(WS) + value("#{path}[#{index}]", depth + 1) + index += 1 + @s.skip(WS) + next if @s.skip(/,/) + break if @s.skip(/\]/) + + raise Invalid, "',' or ']' expected at character #{@s.charpos}" + end + end + + SHORT_ESCAPES = { "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze + + # A member name in single quotes, escaped as a normalized path requires + # (RFC 9535 2.7). + def self.quote(name) + escaped = name.gsub(/[\\']/) { |c| "\\#{c}" } + .gsub(/[\u0000-\u001f]/) { |c| SHORT_ESCAPES[c] || format("\\u%04x", c.ord) } + "'#{escaped}'" + end +end diff --git a/app/services/passport_linter.rb b/app/services/passport_linter.rb index d03ac9d..3c1aecd 100644 --- a/app/services/passport_linter.rb +++ b/app/services/passport_linter.rb @@ -9,7 +9,10 @@ # check.type proof verify an integrity proof in the passport; criteria with # check.type links check the related resources it links to; criteria with # check.type html check the HTML view of the passport (needs a product -# identifier, like resolve). +# identifier, like resolve); criteria with check.type element_ids check that +# the elementIds of the passport are unique (in the compressed serialization +# on `source`, the passport as delivered: the bytes of the response or of the +# posted body). # # A check ends within its time limit (CheckDeadline): a criterion whose # requests are cut off by it, or that would need a request after it, is @@ -28,9 +31,10 @@ def initialize(catalogue: CriteriaCatalogue.new, deadline: CheckDeadline.new, we @didlint = didlint end - def run(passport:, product_id: nil, retrieval: nil, jws: nil, jws_only: false, raw: nil, jws_unchecked: nil) + def run(passport:, product_id: nil, retrieval: nil, jws: nil, jws_only: false, raw: nil, jws_unchecked: nil, source: nil) @product_id = product_id @raw = raw + @source = source || raw @jws = jws @jws_only = jws_only @jws_unchecked = jws_unchecked @@ -106,7 +110,7 @@ def evaluate(criterion, passport, reports) return resolve(base, check) if check["type"] == "resolve" return html(base, check) if check["type"] == "html" - unless %w[shacl did proof links].include?(check["type"]) + unless %w[shacl did proof links element_ids].include?(check["type"]) return skip(base, "check type #{check['type']} is not implemented in this version", "not_evaluated") end return skip(base, "passport could not be retrieved", "not_evaluated") if passport.nil? @@ -119,6 +123,7 @@ def evaluate(criterion, passport, reports) return did(base, check, passport) if check["type"] == "did" return proof(base, check, passport) if check["type"] == "proof" return links(base, check, passport) if check["type"] == "links" + return element_ids(base, check, passport) if check["type"] == "element_ids" return skip(base, "no shapes available for #{check['shapes_select']}", "not_evaluated") unless check["structure"] @@ -176,6 +181,13 @@ def proof(base, check, passport) skip(base, e.message, "not_evaluated") end + def element_ids(base, check, passport) + outcome = ElementIdsCheck.new(check, passport, source: @source).outcome + return skip(base, outcome.skipped, outcome.code) if outcome.skipped + + base.merge(result: result_for(outcome.messages), messages: outcome.messages) + end + def links(base, check, passport) links_check = LinksCheck.new(check, passport, resolver: @resolver) return skip(base, "no #{check['element_type']} elements", "not_applicable") if links_check.resources.empty? diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 2ebc053..8fe73e2 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -26,7 +26,7 @@ spec: app: dpplint containers: - name: dpplint - image: oydeu/dpplint@sha256:bf49962f61eea1998486462af28dc504a0b1f09143554ecf518eeb9298f59094 + image: oydeu/dpplint@sha256:c7cc08a926cf261d3fd3321aaa851343118f3e810ebd0a763312ec21050e1db8 imagePullPolicy: IfNotPresent ports: - containerPort: 3000 diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/invalid/compressed-repeated-member.json b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/compressed-repeated-member.json new file mode 100644 index 0000000..f394e5b --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/compressed-repeated-member.json @@ -0,0 +1 @@ +{"digitalProductPassportId": "https://dpp.example.org/dpp/1", "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", "granularity": "item", "dppSchemaVersion": "EN 18223:2026", "dppStatus": "Active", "lastUpdated": "2026-10-25T00:00:00Z", "economicOperatorId": "did:web:operator.example.org", "performanceMetrics": {"maxPressure": 750.0, "maxPressure": 700.0, "isFireResistant": true}} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-collection-language-variants.json b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-collection-language-variants.json new file mode 100644 index 0000000..a7999f0 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-collection-language-variants.json @@ -0,0 +1,29 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "ProductInformation", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "SafetyInstructions", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Wear eye protection." + }, + { + "elementId": "SafetyInstructions", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Schutzbrille tragen." + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-multi-valued-item-repeated.json b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-multi-valued-item-repeated.json new file mode 100644 index 0000000..62cea31 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-multi-valued-item-repeated.json @@ -0,0 +1,41 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "EnergyPerformance", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "efficiencyRatings", + "objectType": "MultiValuedDataElement", + "value": [ + { + "elementId": "efficiencyRating1", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.95 + }, + { + "elementId": "efficiencyRating2", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.92 + }, + { + "elementId": "efficiencyRating2", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.88 + } + ] + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-top-level-repeated.json b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-top-level-repeated.json new file mode 100644 index 0000000..f912677 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/invalid/full-top-level-repeated.json @@ -0,0 +1,23 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "ModelIdentifier", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "M-1" + }, + { + "elementId": "ModelIdentifier", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "M-2" + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/valid/compressed-unique.json b/test/fixtures/files/element_ids/DPP-DAT-017/valid/compressed-unique.json new file mode 100644 index 0000000..7fdb79f --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/valid/compressed-unique.json @@ -0,0 +1,27 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "performanceMetrics": { + "maxPressure": 750.0, + "isFireResistant": true, + "efficiencyRatings": [ + 0.95, + 0.92 + ] + }, + "componentParts": [ + { + "partId": "A-101", + "material": "Steel" + }, + { + "partId": "B-202", + "material": "Recycled plastic" + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-items-repeat-structure.json b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-items-repeat-structure.json new file mode 100644 index 0000000..ec8e5ac --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-items-repeat-structure.json @@ -0,0 +1,53 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "componentParts", + "objectType": "MultiValuedDataElement", + "elements": [ + { + "elementId": "part1", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "A-101" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Steel" + } + ] + }, + { + "elementId": "part2", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "B-202" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Recycled plastic" + } + ] + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-same-id-in-two-collections.json b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-same-id-in-two-collections.json new file mode 100644 index 0000000..be7936d --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-same-id-in-two-collections.json @@ -0,0 +1,35 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "Manufacturer", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "name", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Example Corp" + } + ] + }, + { + "elementId": "Importer", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "name", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Import Ltd" + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-unique.json b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-unique.json new file mode 100644 index 0000000..f184af0 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-017/valid/full-unique.json @@ -0,0 +1,101 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "ProductIdentification", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "ModelIdentifier", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "M-1" + }, + { + "elementId": "SerialNumber", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "0001" + } + ] + }, + { + "elementId": "EnergyPerformance", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "OnModePower", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 8.5 + }, + { + "elementId": "efficiencyRatings", + "objectType": "MultiValuedDataElement", + "value": [ + { + "elementId": "efficiencyRating1", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.95 + }, + { + "elementId": "efficiencyRating2", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.92 + } + ] + } + ] + }, + { + "elementId": "componentParts", + "objectType": "MultiValuedDataElement", + "elements": [ + { + "elementId": "part1", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "A-101" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Steel" + } + ] + }, + { + "elementId": "part2", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "B-202" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Recycled plastic" + } + ] + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-items-repeat-structure.json b/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-items-repeat-structure.json new file mode 100644 index 0000000..ec8e5ac --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-items-repeat-structure.json @@ -0,0 +1,53 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "componentParts", + "objectType": "MultiValuedDataElement", + "elements": [ + { + "elementId": "part1", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "A-101" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Steel" + } + ] + }, + { + "elementId": "part2", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "B-202" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Recycled plastic" + } + ] + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-unique.json b/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-unique.json new file mode 100644 index 0000000..f184af0 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-021/valid/full-unique.json @@ -0,0 +1,101 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "ProductIdentification", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "ModelIdentifier", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "M-1" + }, + { + "elementId": "SerialNumber", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "0001" + } + ] + }, + { + "elementId": "EnergyPerformance", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "OnModePower", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 8.5 + }, + { + "elementId": "efficiencyRatings", + "objectType": "MultiValuedDataElement", + "value": [ + { + "elementId": "efficiencyRating1", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.95 + }, + { + "elementId": "efficiencyRating2", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.92 + } + ] + } + ] + }, + { + "elementId": "componentParts", + "objectType": "MultiValuedDataElement", + "elements": [ + { + "elementId": "part1", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "A-101" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Steel" + } + ] + }, + { + "elementId": "part2", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "partId", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "B-202" + }, + { + "elementId": "material", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Recycled plastic" + } + ] + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-at-two-depths.json b/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-at-two-depths.json new file mode 100644 index 0000000..a0ab6d2 --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-at-two-depths.json @@ -0,0 +1,29 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "weight", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 1.2 + }, + { + "elementId": "Packaging", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "weight", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:decimal", + "value": 0.1 + } + ] + } + ] +} diff --git a/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-in-two-collections.json b/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-in-two-collections.json new file mode 100644 index 0000000..be7936d --- /dev/null +++ b/test/fixtures/files/element_ids/DPP-DAT-021/warning/full-same-id-in-two-collections.json @@ -0,0 +1,35 @@ +{ + "digitalProductPassportId": "https://dpp.example.org/dpp/1", + "uniqueProductIdentifier": "https://dpp.example.org/01/09520123456788/21/1", + "granularity": "item", + "dppSchemaVersion": "EN 18223:2026", + "dppStatus": "Active", + "lastUpdated": "2026-10-25T00:00:00Z", + "economicOperatorId": "did:web:operator.example.org", + "elements": [ + { + "elementId": "Manufacturer", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "name", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Example Corp" + } + ] + }, + { + "elementId": "Importer", + "objectType": "DataElementCollection", + "elements": [ + { + "elementId": "name", + "objectType": "SingleValuedDataElement", + "valueDataType": "xsd:string", + "value": "Import Ltd" + } + ] + } + ] +} diff --git a/test/fixtures/files/html/DPP-SEC-021/invalid/event-handler-notifications.html b/test/fixtures/files/html/DPP-SEC-021/invalid/event-handler-notifications.html new file mode 100644 index 0000000..a2ca338 --- /dev/null +++ b/test/fixtures/files/html/DPP-SEC-021/invalid/event-handler-notifications.html @@ -0,0 +1,11 @@ + + +
+ +Model M-1
+ + diff --git a/test/fixtures/files/html/DPP-SEC-021/valid/own-script-without-device-use.html b/test/fixtures/files/html/DPP-SEC-021/valid/own-script-without-device-use.html new file mode 100644 index 0000000..84e172f --- /dev/null +++ b/test/fixtures/files/html/DPP-SEC-021/valid/own-script-without-device-use.html @@ -0,0 +1,11 @@ + + + + +