From 4f11b3687dc7be879681a5e0d22d8a9d01bc9240 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Thu, 9 Jan 2025 13:16:18 +0100 Subject: [PATCH 01/18] refactor(nbp): decaffeinate nbp_wallet.coffee - Increase code robustness Use `setTimeout` instead of `setInterval` to prevent queued up XHR requests returning unordered in case of e.g. an unresponsive server Part of XI-6523 --- app/assets/javascripts/nbp_wallet.coffee | 26 -------------- app/assets/javascripts/nbp_wallet.js | 43 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 26 deletions(-) delete mode 100644 app/assets/javascripts/nbp_wallet.coffee create mode 100644 app/assets/javascripts/nbp_wallet.js diff --git a/app/assets/javascripts/nbp_wallet.coffee b/app/assets/javascripts/nbp_wallet.coffee deleted file mode 100644 index c3f262592..000000000 --- a/app/assets/javascripts/nbp_wallet.coffee +++ /dev/null @@ -1,26 +0,0 @@ -template_validity = 0 -finalizing = false - -checkStatus = -> - if !finalizing && window.location.pathname == Routes.nbp_wallet_connect_users_path() - $.ajax( - url: Routes.nbp_wallet_relationship_status_users_path() - success: (data, textStatus, xhr) -> - if data.status == 'ready' && !finalizing - finalizing = true - window.location = Routes.nbp_wallet_finalize_users_path() - ) - -countdownValidity = -> - if template_validity > 0 - template_validity -= 1 - if template_validity == 0 - window.location.reload() - -$(document).on 'turbolinks:load', () -> - # subtracting 5 seconds to make sure the displayed code is always valid (accounting for loading times) - template_validity = $('.nbp_wallet_qr_code').data('template-validity') - 5 - - setInterval checkStatus, 1000 - setInterval countdownValidity, 1000 - $('.regenerate-qr-code-button').on 'click', (event) -> window.location.reload(); diff --git a/app/assets/javascripts/nbp_wallet.js b/app/assets/javascripts/nbp_wallet.js new file mode 100644 index 000000000..edde38cf0 --- /dev/null +++ b/app/assets/javascripts/nbp_wallet.js @@ -0,0 +1,43 @@ +let templateValidity = 0; +let finalizing = false; + +const checkStatus = async () => { + if (!finalizing) { + try { + const response = await fetch('/nbp_wallet/relationship_status'); + const json = await response.json(); + + if (json.status === 'ready' && !finalizing) { + finalizing = true; + window.location.pathname = '/nbp_wallet/finalize'; + } + } catch (error) { + console.error(error); + } + } + setTimeout(checkStatus, 1000); +}; + +const countdownValidity = () => { + if (templateValidity > 0) { + templateValidity -= 1; + } + if (templateValidity === 0) { + window.location.reload(); + } +}; + +$(document).on('turbolinks:load', function () { + if (window.location.pathname !== '/nbp_wallet/connect') { + return; + } + + document.querySelector('.regenerate-qr-code-button').addEventListener('click', () => { + window.location.reload(); + }); + + // Subtract 5 seconds to make sure the displayed code is always valid (accounting for loading times) + templateValidity = document.querySelector('[data-id="nbp_wallet_qr_code"]').dataset.remainingValidity - 5; + checkStatus(); + setInterval(countdownValidity, 1000); +}); From 8851640c4ff1668ea1ce3e81751f01339a39a781 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Thu, 9 Jan 2025 13:24:48 +0100 Subject: [PATCH 02/18] fix(enmeshed): parse expiration time of `RelationshipTemplate` inversibly `RelationshipTemplate#remaining_validity` requires the attribute `expire_at` to be of type `ActiveSupport::TimeWithZone` to be able to perform `minus_without_duration`. Part of XI-6523 --- lib/enmeshed/relationship_template.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/enmeshed/relationship_template.rb b/lib/enmeshed/relationship_template.rb index 29df987bf..bde5bbd78 100644 --- a/lib/enmeshed/relationship_template.rb +++ b/lib/enmeshed/relationship_template.rb @@ -29,7 +29,7 @@ def self.parse(content) super attributes = { truncated_reference: content[:truncatedReference], - expires_at: DateTime.parse(content[:expiresAt]), + expires_at: Time.zone.parse(content[:expiresAt]), nbp_uid: content.dig(:content, :metadata, :nbp_uid), } new(**attributes) From 7a05d0ba28562a7f20ccccd89785ccb39768220a Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Thu, 9 Jan 2025 13:51:12 +0100 Subject: [PATCH 03/18] style(enmeshed): group class methods in the lib - Group class methods with `class << self` - Write out abbreviations - Enmeshed::Connector: Combine initialization and memoization of the Faraday connection into a single method (no expensive calculations here) Part of XI-6523 --- .../users/nbp_wallet_controller.rb | 6 +- lib/enmeshed/attribute.rb | 2 +- lib/enmeshed/connector.rb | 185 +++++++++--------- lib/enmeshed/object.rb | 24 +-- lib/enmeshed/relationship.rb | 41 ++-- lib/enmeshed/relationship_template.rb | 60 +++--- spec/lib/enmeshed/connector_spec.rb | 6 +- 7 files changed, 169 insertions(+), 155 deletions(-) diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index 7028af975..bb9e9ad9d 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -7,7 +7,7 @@ class NbpWalletController < ApplicationController skip_after_action :verify_authorized def connect - if Enmeshed::Relationship.pending_for_nbp_uid(@provider_uid).present? + if Enmeshed::Relationship.pending_for(@provider_uid).present? redirect_to nbp_wallet_finalize_users_path and return end @@ -24,7 +24,7 @@ def qr_code end def relationship_status - if Enmeshed::Relationship.pending_for_nbp_uid(@provider_uid).present? + if Enmeshed::Relationship.pending_for(@provider_uid).present? render json: {status: :ready} else render json: {status: :waiting} @@ -36,7 +36,7 @@ def relationship_status end def finalize - relationship = Enmeshed::Relationship.pending_for_nbp_uid(@provider_uid) + relationship = Enmeshed::Relationship.pending_for(@provider_uid) abort_and_refresh(relationship) and return if relationship.blank? accept_and_create_user(relationship) diff --git a/lib/enmeshed/attribute.rb b/lib/enmeshed/attribute.rb index bb10568ad..a3c882f30 100644 --- a/lib/enmeshed/attribute.rb +++ b/lib/enmeshed/attribute.rb @@ -19,7 +19,7 @@ def self.parse(content) super attribute_type = content.dig(:content, :@type) - desired_klass = descendants&.find {|descendant| descendant.klass == attribute_type } + desired_klass = descendants.find {|descendant| descendant.klass == attribute_type } raise ConnectorError.new("Unknown attribute type: #{attribute_type}") unless desired_klass attributes = { diff --git a/lib/enmeshed/connector.rb b/lib/enmeshed/connector.rb index bec58bb43..bee3f769c 100644 --- a/lib/enmeshed/connector.rb +++ b/lib/enmeshed/connector.rb @@ -8,119 +8,124 @@ class Connector CONNECTOR_URL = Settings.dig(:omniauth, :nbp, :enmeshed, :connector_url) API_SCHEMA = JSONSchemer.openapi(YAML.safe_load_file(Rails.root.join('lib/enmeshed/api_schema.yml'), permitted_classes: [Time, Date])) - # @return [String] The address of the enmeshed account. - def self.enmeshed_address - return @enmeshed_address if @enmeshed_address.present? + class << self + # @return [String] The address of the enmeshed account. + def enmeshed_address + return @enmeshed_address if @enmeshed_address.present? - identity = parse_result(conn.get('/api/v2/Account/IdentityInfo'), IdentityInfo) - @enmeshed_address = identity.address - end - - # @return [String] The ID of the created attribute. - def self.create_attribute(attribute) - response = conn.post('/api/v2/Attributes') do |req| - req.body = {content: attribute.to_h}.to_json + identity = parse_result(connection.get('/api/v2/Account/IdentityInfo'), IdentityInfo) + @enmeshed_address = identity.address end - parse_result(response, Attribute).id - end - # @return [String, nil] The ID of the existing attribute or nil if none was found. - def self.fetch_existing_attribute(attribute) # rubocop:disable Metrics/AbcSize - response = conn.get('/api/v2/Attributes') do |req| - req.params['content.@type'] = attribute.klass - req.params['content.owner'] = attribute.owner - req.params['content.value.@type'] = attribute.type + # @return [String] The ID of the created attribute. + def create_attribute(attribute) + response = connection.post('/api/v2/Attributes') do |request| + request.body = {content: attribute.to_h}.to_json + end + parse_result(response, Attribute).id end - parse_result(response, Attribute).find {|attr| attr.value == attribute.value }&.id - end - # @return [String] The truncated reference of the created relationship template. - def self.create_relationship_template(relationship_template) - response = conn.post('/api/v2/RelationshipTemplates/Own') do |req| - req.body = relationship_template.to_json + # @return [String, nil] The ID of the existing attribute or nil if none was found. + def fetch_existing_attribute(attribute) + response = connection.get('/api/v2/Attributes') do |request| + request.params['content.@type'] = attribute.klass + request.params['content.owner'] = attribute.owner + request.params['content.value.@type'] = attribute.type + end + parse_result(response, Attribute).find {|attr| attr.value == attribute.value }&.id end - new_template = parse_result(response, RelationshipTemplate) - Rails.logger.debug { "Enmeshed::ConnectorApi RelationshipTemplate created: #{new_template.truncated_reference}" } - new_template.truncated_reference - end - - # @return [RelationshipTemplate, nil] The relationship template with the given truncated reference or nil if none was found. - def self.fetch_existing_relationship_template(truncated_reference) - response = conn.get('/api/v2/RelationshipTemplates') do |req| - req.params['isOwn'] = true + # @return [String] The truncated reference of the created relationship template. + def create_relationship_template(relationship_template) + response = connection.post('/api/v2/RelationshipTemplates/Own') do |request| + request.body = relationship_template.to_json + end + new_template = parse_result(response, RelationshipTemplate) + + Rails.logger.debug do + "Enmeshed::ConnectorApi RelationshipTemplate created: #{new_template.truncated_reference}" + end + new_template.truncated_reference end - parse_result(response, RelationshipTemplate).find {|template| template.truncated_reference == truncated_reference } - end - # @return [Array] All relationships that are pending and awaiting further processing. - def self.pending_relationships - response = conn.get('/api/v2/Relationships') do |req| - req.params['status'] = 'Pending' + # @return [RelationshipTemplate, nil] The relationship template with the given truncated reference or nil if none + # was found. + def fetch_existing_relationship_template(truncated_reference) + response = connection.get('/api/v2/RelationshipTemplates') do |request| + request.params['isOwn'] = true + end + parse_result(response, RelationshipTemplate).find do |template| + template.truncated_reference == truncated_reference + end end - parse_result(response, Relationship) - end - # @return [Boolean] Whether the relationship change was changed (accepted or rejected) successfully. - def self.respond_to_rel_change(relationship_id, change_id, action = 'Accept') - response = conn.put("/api/v2/Relationships/#{relationship_id}/Changes/#{change_id}/#{action}") do |req| - req.body = {content: {}}.to_json + # @return [Array] All relationships that are pending and await further processing. + def pending_relationships + response = connection.get('/api/v2/Relationships') do |request| + request.params['status'] = 'Pending' + end + parse_result(response, Relationship) end - Rails.logger.debug do - "Enmeshed::ConnectorApi responded to RelationshipChange with: #{action}; connector response status is #{response.status}" + + # @return [Boolean] Whether the relationship change was changed (accepted or rejected) successfully. + def respond_to_rel_change(relationship_id, change_id, action = 'Accept') + response = + connection.put("/api/v2/Relationships/#{relationship_id}/Changes/#{change_id}/#{action}") do |request| + request.body = {content: {}}.to_json + end + Rails.logger.debug do + "Enmeshed::ConnectorApi responded to RelationshipChange with: #{action}; " \ + "connector response status is #{response.status}" + end + + response.status == 200 end - response.status == 200 - end + private + + # @return [klass, Array] + # @raise [ConnectorError] If the response contains an error or cannot be parsed. + def parse_result(response, klass) + json = JSON.parse(response.body).deep_symbolize_keys - # @return [klass, Array] - # @raise [ConnectorError] If the response contains an error or cannot be parsed. - def self.parse_result(response, klass) - json = JSON.parse(response.body).deep_symbolize_keys + if json.include?(:error) + raise ConnectorError.new( + "Enmeshed connector response contained error: #{json[:error][:message]}. " \ + "Full response was: #{response.body}" + ) + end - if json.include?(:error) - raise ConnectorError.new( - "Enmeshed connector response contained error: #{json[:error][:message]}. Full response was: #{response.body}" - ) + parse_enmeshed_object(json[:result], klass) + rescue JSON::ParserError + raise ConnectorError.new("Enmeshed connector response could not be parsed. Received: #{response.body}") end - parse_enmeshed_object(json[:result], klass) - rescue JSON::ParserError - raise ConnectorError.new("Enmeshed connector response could not be parsed. Received: #{response.body}") - end - private_class_method :parse_result - - # @return [klass, Array] The parsed object or array of objects. - def self.parse_enmeshed_object(content, klass) - if content.is_a?(Array) - content.map {|object| klass.parse(object) } - else - klass.parse(content) + # @return [klass, Array] The parsed object or array of objects. + def parse_enmeshed_object(content, klass) + if content.is_a?(Array) + content.map {|object| klass.parse(object) } + else + klass.parse(content) + end end - end - private_class_method :parse_enmeshed_object - # @return [Faraday::Connection] The connection to the enmeshed connector. - def self.conn - @conn ||= init_conn - end - private_class_method :conn + # @return [Faraday::Connection] The connection to the enmeshed connector. + # @raise [ConnectorError] If the connector is not configured as expected. + def connection + return @connection if @connection.present? - # @return [Faraday::Connection] A new connection to the enmeshed connector. - # @raise [ConnectorError] If the connector is not configured as expected. - def self.init_conn - if User.omniauth_providers.exclude?(:nbp) || CONNECTOR_URL.nil? || API_KEY.nil? || RelationshipTemplate::DISPLAY_NAME.nil? - raise ConnectorError.new('NBP provider or enmeshed connector not configured as expected') - end + unless User.omniauth_providers.include?(:nbp) && CONNECTOR_URL.present? && API_KEY.present? \ + && RelationshipTemplate::DISPLAY_NAME.present? + raise ConnectorError.new('NBP provider or enmeshed connector not configured as expected') + end - Faraday.new(CONNECTOR_URL, headers:) - end - private_class_method :init_conn + @connection = Faraday.new(CONNECTOR_URL, headers:) + end - # @return [Hash] The headers for the enmeshed connection. - def self.headers - {'X-API-KEY': API_KEY, 'content-type': 'application/json', accept: 'application/json'} + # @return [Hash] The headers for the enmeshed connection. + def headers + {'X-API-KEY': API_KEY, 'content-type': 'application/json', accept: 'application/json'} + end end - private_class_method :headers end end diff --git a/lib/enmeshed/object.rb b/lib/enmeshed/object.rb index b680f9ff6..cb0c042df 100644 --- a/lib/enmeshed/object.rb +++ b/lib/enmeshed/object.rb @@ -4,20 +4,22 @@ module Enmeshed class Object delegate :klass, to: :class - def self.parse(content) - validate! content - end + class << self + def parse(content) + validate! content + end - def self.klass - name&.demodulize - end + def klass + name&.demodulize + end - def self.schema - @schema ||= Connector::API_SCHEMA.schema(klass) - end + def schema + @schema ||= Connector::API_SCHEMA.schema(klass) + end - def self.validate!(instance) - raise ConnectorError.new("Invalid #{klass} schema") unless schema.valid?(instance) + def validate!(instance) + raise ConnectorError.new("Invalid #{klass} schema") unless schema.valid?(instance) + end end end end diff --git a/lib/enmeshed/relationship.rb b/lib/enmeshed/relationship.rb index f9d2f37e3..36f876029 100644 --- a/lib/enmeshed/relationship.rb +++ b/lib/enmeshed/relationship.rb @@ -13,23 +13,6 @@ def initialize(json:, template:, changes: []) @relationship_changes = changes end - def self.parse(content) - super - attributes = { - json: content, - template: RelationshipTemplate.parse(content[:template]), - changes: content[:changes], - } - new(**attributes) - end - - def self.pending_for_nbp_uid(nbp_uid) - relationships = Connector.pending_relationships - - # We want to call valid? for all relationships because it internally rejects invalid relationships - relationships.select(&:valid?).find {|rel| rel.nbp_uid == nbp_uid } - end - def peer @json[:peer] end @@ -39,7 +22,8 @@ def userdata end def valid? - # templates can only be scanned in their validity period but can theoretically be submitted infinitely late so we sanitize here + # Templates can only be scanned in their validity period but can theoretically be submitted infinitely late. + # Thus, we sanitize here. if expires_at < (RelationshipTemplate::VALIDITY_PERIOD * 2).ago reject! false @@ -53,7 +37,7 @@ def id end def accept! - raise ConnectorError('Relationship should exactly one RelationshipChange') if relationship_changes.size != 1 + raise ConnectorError('Relationship should have exactly one RelationshipChange') if relationship_changes.size != 1 Rails.logger.debug do "Enmeshed::ConnectorApi accepting Relationship for template #{truncated_reference}" @@ -72,6 +56,25 @@ def reject! end end + class << self + def parse(content) + super + attributes = { + json: content, + template: RelationshipTemplate.parse(content[:template]), + changes: content[:changes], + } + new(**attributes) + end + + def pending_for(nbp_uid) + relationships = Connector.pending_relationships + + # We want to call valid? for all relationships, because it internally rejects invalid relationships + relationships.select(&:valid?).find {|relationship| relationship.nbp_uid == nbp_uid } + end + end + private def parse_userdata # rubocop:disable Metrics/AbcSize diff --git a/lib/enmeshed/relationship_template.rb b/lib/enmeshed/relationship_template.rb index bde5bbd78..168811800 100644 --- a/lib/enmeshed/relationship_template.rb +++ b/lib/enmeshed/relationship_template.rb @@ -4,7 +4,8 @@ module Enmeshed class RelationshipTemplate < Object # The app does not allow users to scan expired templates. # However, previously scanned and then expired templates can still be submitted, - # resulting in the app silently doing nothing. CodeHarbor would still accept Relationships for expired templates if sent by the app. + # resulting in the app silently doing nothing. CodeHarbor would still accept Relationships for expired templates if + # sent by the app. # To minimize the risk of a template expiring before submission, we set the validity to 12 hours. VALIDITY_PERIOD = 12.hours # The display name of the service as shown in the enmeshed app. @@ -25,20 +26,6 @@ def initialize(truncated_reference: nil, nbp_uid: nil, expires_at: VALIDITY_PERI @expires_at = expires_at end - def self.parse(content) - super - attributes = { - truncated_reference: content[:truncatedReference], - expires_at: Time.zone.parse(content[:expiresAt]), - nbp_uid: content.dig(:content, :metadata, :nbp_uid), - } - new(**attributes) - end - - def self.fetch(truncated_reference) - Connector.fetch_existing_relationship_template(truncated_reference) || new(truncated_reference:) - end - def create! @truncated_reference = Connector.create_relationship_template self self @@ -74,19 +61,6 @@ def remaining_validity [expires_at - Time.zone.now, 0].max end - def self.create!(nbp_uid:) - new(nbp_uid:).create! - end - - def self.display_name_attribute - @display_name_attribute ||= Attribute::Identity.new(type: 'DisplayName', value: DISPLAY_NAME) - end - - def self.allow_certificate_request - # i18n-tasks-use t('users.nbp_wallet.enmeshed.AllowCertificateRequest') - @allow_certificate_request ||= Attribute::Relationship.new(type: 'ProprietaryBoolean', key: 'AllowCertificateRequest', value: true) - end - def to_json(*) { maxNumberOfAllocations: 1, @@ -102,6 +76,36 @@ def to_json(*) }.to_json(*) end + class << self + def parse(content) + super + attributes = { + truncated_reference: content[:truncatedReference], + expires_at: Time.zone.parse(content[:expiresAt]), + nbp_uid: content.dig(:content, :metadata, :nbp_uid), + } + new(**attributes) + end + + def fetch(truncated_reference) + Connector.fetch_existing_relationship_template(truncated_reference) || new(truncated_reference:) + end + + def create!(nbp_uid:) + new(nbp_uid:).create! + end + + def display_name_attribute + @display_name_attribute ||= Attribute::Identity.new(type: 'DisplayName', value: DISPLAY_NAME) + end + + def allow_certificate_request + # i18n-tasks-use t('account.nbp_wallet.enmeshed.AllowCertificateRequest') + @allow_certificate_request ||= Attribute::Relationship.new(type: 'ProprietaryBoolean', + key: 'AllowCertificateRequest', value: true) + end + end + private def shared_attributes diff --git a/spec/lib/enmeshed/connector_spec.rb b/spec/lib/enmeshed/connector_spec.rb index fac3ae02b..68718e226 100644 --- a/spec/lib/enmeshed/connector_spec.rb +++ b/spec/lib/enmeshed/connector_spec.rb @@ -58,13 +58,13 @@ end end - describe '.init_conn' do + describe '.connection' do before do allow(User).to receive(:omniauth_providers).and_return([:nbp]) end it 'returns a Faraday connection' do - expect(connector.send(:init_conn)).to be_a(Faraday::Connection) + expect(connector.send(:connection)).to be_a(Faraday::Connection) end context 'when the config is invalid' do @@ -73,7 +73,7 @@ end it 'raises an error' do - expect { connector.send(:init_conn) }.to raise_error(Enmeshed::ConnectorError) + expect { connector.send(:connection) }.to raise_error(Enmeshed::ConnectorError) end end end From f7a5ca2e35caaa88531f9e21d1e31b37cfe45a5c Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Tue, 14 Jan 2025 14:29:05 +0100 Subject: [PATCH 04/18] fix(enmeshed): Keep the Faraday connection alive and add explicit timeouts Memorizing the connection without using Faraday's adapter `net_http_persistent` does not come with any benefits (every request requires setting up a new TCP socket with the default adapter). Part of XI-6523 --- Gemfile | 1 + Gemfile.lock | 8 ++ lib/enmeshed/connector.rb | 7 +- spec/lib/enmeshed/connector_spec.rb | 89 ++++++++++++++++++- .../enmeshed/relationship_template_spec.rb | 2 +- 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 234085a06..99d0399ac 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'coffee-rails', require: false gem 'config' gem 'devise-bootstrap-views' gem 'faraday' +gem 'faraday-net_http_persistent' gem 'http_accept_language' gem 'i18n-js' gem 'image_processing' diff --git a/Gemfile.lock b/Gemfile.lock index 2d0303d05..f9161974b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,6 +167,9 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.0) net-http (>= 0.5.0) + faraday-net_http_persistent (2.3.0) + faraday (~> 2.5) + net-http-persistent (>= 4.0.4, < 5) ffi (1.17.1) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) @@ -276,6 +279,8 @@ GEM rails (>= 3.2.0) net-http (0.6.0) uri + net-http-persistent (4.0.5) + connection_pool (~> 2.2) net-imap (0.5.6) date net-protocol @@ -613,6 +618,7 @@ DEPENDENCIES devise-bootstrap-views factory_bot_rails faraday + faraday-net_http_persistent http_accept_language i18n-js i18n-tasks @@ -741,6 +747,7 @@ CHECKSUMS faraday (2.12.2) sha256=157339c25c7b8bcb739f5cf1207cb0cefe8fa1c65027266bcbc34c90c84b9ad6 faraday-multipart (1.1.0) sha256=856b0f1c7316a4d6c052dd2eef5c42f887d56d93a171fe8880da1af064ca0751 faraday-net_http (3.4.0) sha256=a1f1e4cd6a2cf21599c8221595e27582d9936819977bbd4089a601f24c64e54a + faraday-net_http_persistent (2.3.0) sha256=33d4948cabe9f8148222c4ca19634c71e1f25595cccf9da2e02ace8d754f1bb1 ffi (1.17.1) sha256=26f6b0dbd1101e6ffc09d3ca640b2a21840cc52731ad8a7ded9fb89e5fb0fc39 fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 glob (0.4.1) sha256=e68e50419ffb7f896b39a483c1a37e7a1aa8f1a8c8ea13961f8cd1b50f40715d @@ -787,6 +794,7 @@ CHECKSUMS nested_form (0.3.2) sha256=b1c468d7eac781235861c2f74fc9f675df0c4d915d5724aaf7fd29f7891c0538 nested_form_fields (0.8.4) sha256=e3db8e935b40c6b6027ce65b10ee0c5cf575d1ba175be85154c81d4253635b19 net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb + net-http-persistent (4.0.5) sha256=6e42880b347e650ffeaf679ae59c9d5a6ed8a22cda6e1b959d9c270050aefa8e net-imap (0.5.6) sha256=1ede8048ee688a14206060bf37a716d18cb6ea00855f6c9b15daee97ee51fbe5 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 diff --git a/lib/enmeshed/connector.rb b/lib/enmeshed/connector.rb index bee3f769c..4c0294a51 100644 --- a/lib/enmeshed/connector.rb +++ b/lib/enmeshed/connector.rb @@ -119,7 +119,12 @@ def connection raise ConnectorError.new('NBP provider or enmeshed connector not configured as expected') end - @connection = Faraday.new(CONNECTOR_URL, headers:) + @connection = Faraday.new(CONNECTOR_URL, headers:) do |faraday| + faraday.options[:open_timeout] = 1 + faraday.options[:timeout] = 5 + + faraday.adapter :net_http_persistent + end end # @return [Hash] The headers for the enmeshed connection. diff --git a/spec/lib/enmeshed/connector_spec.rb b/spec/lib/enmeshed/connector_spec.rb index 68718e226..e59ea3d44 100644 --- a/spec/lib/enmeshed/connector_spec.rb +++ b/spec/lib/enmeshed/connector_spec.rb @@ -4,6 +4,11 @@ RSpec.describe Enmeshed::Connector do let(:connector) { described_class } + let(:connector_api_url) { "#{Settings.dig(:omniauth, :nbp, :enmeshed, :connector_url)}/api/v2" } + + before do + allow(User).to receive(:omniauth_providers).and_return([:nbp]) + end describe '.parse_result' do let(:response) { Faraday::Response.new(body:) } @@ -59,16 +64,14 @@ end describe '.connection' do - before do - allow(User).to receive(:omniauth_providers).and_return([:nbp]) - end - it 'returns a Faraday connection' do expect(connector.send(:connection)).to be_a(Faraday::Connection) end context 'when the config is invalid' do before do + # Un-memoize the connection to re-read the config + connector.instance_variable_set(:@connection, nil) allow(User).to receive(:omniauth_providers).and_return([]) end @@ -77,4 +80,82 @@ end end end + + describe '.enmeshed_address' do + subject(:enmeshed_address) { connector.enmeshed_address } + + before do + stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") + .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) + end + + it 'returns the parsed address' do + expect(enmeshed_address).to eq 'id_of_an_example_enmeshed_address_AB' + end + end + + describe '.create_relationship_template' do + subject(:create_relationship_template) { connector.create_relationship_template(relationship_template) } + + let(:relationship_template) { instance_double(Enmeshed::RelationshipTemplate) } + + before do + stub_request(:post, "#{connector_api_url}/RelationshipTemplates/Own") + .to_return(body: file_fixture('enmeshed/relationship_template_created.json')) + end + + it 'returns the truncated reference of the RelationshipTemplate' do + expect(create_relationship_template).to eq 'RelationshipTemplateExampleTruncatedReferenceA==' + end + end + + describe '.pending_relationships' do + subject(:pending_relationships) { connector.pending_relationships } + + before do + stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") + .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) + end + + it 'returns a parsed relationship' do + expect(pending_relationships.first).to be_an Enmeshed::Relationship + end + end + + describe '.respond_to_rel_change' do + subject(:respond_to_rel_change) { connector.respond_to_rel_change(relationship_id, change_id) } + + let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/#{relationship_id}/Changes/#{change_id}/Accept") } + + let(:relationship_id) { 'RELoi9IL4adMbj92K8dn' } + let(:change_id) { 'RCHNFJ9JD2LayPxn79nO' } + + context 'with a successful response' do + before do + accept_request_stub + end + + it 'is true' do + expect(respond_to_rel_change).to be_truthy + end + end + + context 'with a failed response' do + before do + accept_request_stub.to_return(status: 500) + end + + it 'is false' do + expect(respond_to_rel_change).to be false + end + end + end + + context 'when the connector is down' do + before { stub_request(:get, "#{connector_api_url}/Relationships?status=Pending").and_timeout } + + it 'raises an error' do + expect { connector.pending_relationships }.to raise_error(Faraday::TimeoutError) + end + end end diff --git a/spec/lib/enmeshed/relationship_template_spec.rb b/spec/lib/enmeshed/relationship_template_spec.rb index 8db45a4d3..a65682cc7 100644 --- a/spec/lib/enmeshed/relationship_template_spec.rb +++ b/spec/lib/enmeshed/relationship_template_spec.rb @@ -66,7 +66,7 @@ before { get_relationship_templates_stub.to_timeout } it 'raises an error' do - expect { new_template }.to raise_error(Faraday::ConnectionFailed) + expect { new_template }.to raise_error(Faraday::TimeoutError) end end end From efe60df6aa8f373ebb5c859d7a5d21e9883c07f4 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Wed, 15 Jan 2025 13:26:04 +0100 Subject: [PATCH 05/18] fix(enmeshed): add initialization of `ConnectorError` where missing --- lib/enmeshed/relationship.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/enmeshed/relationship.rb b/lib/enmeshed/relationship.rb index 36f876029..0b7328ce1 100644 --- a/lib/enmeshed/relationship.rb +++ b/lib/enmeshed/relationship.rb @@ -37,7 +37,9 @@ def id end def accept! - raise ConnectorError('Relationship should have exactly one RelationshipChange') if relationship_changes.size != 1 + if relationship_changes.size != 1 + raise ConnectorError.new('Relationship should have exactly one RelationshipChange') + end Rails.logger.debug do "Enmeshed::ConnectorApi accepting Relationship for template #{truncated_reference}" @@ -81,7 +83,9 @@ def parse_userdata # rubocop:disable Metrics/AbcSize # Since the RelationshipTemplate has a `maxNumberOfAllocations` attribute set to 1, # you cannot request multiple Relationships with the same template. # Further, RelationshipChanges should not be possible before accepting the Relationship. - raise ConnectorError('Relationship should have exactly one RelationshipChange') if relationship_changes.size != 1 + if relationship_changes.size != 1 + raise ConnectorError.new('Relationship should have exactly one RelationshipChange') + end change_response_items = relationship_changes.first.dig(:request, :content, :response, :items) From 799a4efe3fb093d1cf6cdceeaedf24b4b55f2750 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Wed, 15 Jan 2025 14:13:30 +0100 Subject: [PATCH 06/18] feat(tests): set session variables during request specs Since RSpec 3.5, controller specs are deprecated. The official recommendation of the Rails team and the RSpec core team is to write request specs instead. They involve the router, the middleware stack, and both rack requests and responses. Thus, it's not possible to set the session variables beforehand anymore. Instead, a request spec should call the sign in endpoint before calling the actual endpoint under test, when the session is needed. To avoid the complexity of SSO and SLOs during request tests, this helper introduces the option to set the session variables via a designated endpoint for tests. https://gist.github.com/dteoh/99721c0321ccd18286894a962b5ce584?permalink_comment_id=4188995#gistcomment-4188995 --- spec/support/request.rb | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 spec/support/request.rb diff --git a/spec/support/request.rb b/spec/support/request.rb new file mode 100644 index 000000000..ae9abf7f0 --- /dev/null +++ b/spec/support/request.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Testing + # Since RSpec 3.5, controller specs are deprecated. The official recommendation of the Rails team and the RSpec core + # team is to write request specs instead. They involve the router, the middleware stack, and both rack requests and + # responses. Thus, it's not possible to set the session variables beforehand anymore. Instead, a request spec should + # call the sign in endpoint before calling the actual endpoint under test, when the session is needed. + + # To avoid the complexity of SSO and SLOs during request tests, this helper introduces the option to set the session + # variables via a designated endpoint for tests. + # https://gist.github.com/dteoh/99721c0321ccd18286894a962b5ce584?permalink_comment_id=4188995#gistcomment-4188995 + + class SessionsController < ApplicationController + skip_before_action :require_user! + skip_after_action :verify_authorized + def create + vars = params.permit(session_vars: {}) + vars[:session_vars]&.each do |var, value| + session[var] = value + end + head :created + end + end + + module RequestSessionHelper + def set_session(vars = {}) + post testing_session_path, params: {session_vars: vars} + expect(response).to have_http_status(:created) + + vars.each_key do |var| + expect(session[var]).to be_present + end + end + end +end + +RSpec.configure do |config| + config.include Testing::RequestSessionHelper + + config.before(:all, type: :request) do + Rails.application.routes.send(:eval_block, proc do + namespace :testing do + resource :session, only: %i[create] + end + end) + end +end From 39e0c724fdaee5c63de7cda60cc96e4e99702e72 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Wed, 15 Jan 2025 14:46:52 +0100 Subject: [PATCH 07/18] test(enmeshed): add more lib specs Part of XI-6523 --- ...reated.json => relationship_template.json} | 0 spec/lib/enmeshed/relationship_spec.rb | 154 ++++++++++++++++++ .../enmeshed/relationship_template_spec.rb | 151 +++++++++++++++-- 3 files changed, 293 insertions(+), 12 deletions(-) rename spec/fixtures/files/enmeshed/{valid_relationship_template_created.json => relationship_template.json} (100%) create mode 100644 spec/lib/enmeshed/relationship_spec.rb diff --git a/spec/fixtures/files/enmeshed/valid_relationship_template_created.json b/spec/fixtures/files/enmeshed/relationship_template.json similarity index 100% rename from spec/fixtures/files/enmeshed/valid_relationship_template_created.json rename to spec/fixtures/files/enmeshed/relationship_template.json diff --git a/spec/lib/enmeshed/relationship_spec.rb b/spec/lib/enmeshed/relationship_spec.rb new file mode 100644 index 000000000..57327c4a0 --- /dev/null +++ b/spec/lib/enmeshed/relationship_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Enmeshed::Relationship do + let(:connector_api_url) { "#{Settings.dig(:omniauth, :nbp, :enmeshed, :connector_url)}/api/v2"} + let(:json) do + JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, + symbolize_names: true)[:result].first + end + let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } + + before do + allow(User).to receive(:omniauth_providers).and_return([:nbp]) + end + + describe '.pending_for' do + subject(:pending_for) { described_class.pending_for(nbp_uid) } + + let(:nbp_uid) { 'example_uid' } + let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } + + before do + stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") + .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) + end + + it 'returns the pending relationship with the requested nbp_uid' do + expect(pending_for.nbp_uid).to eq nbp_uid + end + + it 'does not reject the relationship' do + pending_for + expect(reject_request_stub).not_to have_been_requested + end + + context 'with an expired relationship' do + before do + stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") + .to_return(body: file_fixture('enmeshed/relationship_expired.json')) + reject_request_stub + end + + it 'rejects the relationship' do + pending_for + expect(reject_request_stub).to have_been_requested + end + + it 'returns nothing' do + expect(pending_for).to be_nil + end + end + end + + describe '#accept!' do + subject(:accept) { described_class.new(json:, template:, changes: json[:changes]).accept! } + + let(:json) { JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, symbolize_names: true)[:result].first } + let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } + let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Accept") } + + before do + accept_request_stub + end + + it 'accepts' do + expect(accept).to be_truthy + expect(accept_request_stub).to have_been_requested + end + + context 'without a RelationshipChange' do + subject(:accept) { described_class.new(json:, template:, changes: []).accept! } + + it 'raises an error' do + expect { accept }.to raise_error Enmeshed::ConnectorError + end + end + end + + describe '#reject!' do + subject(:reject) { described_class.new(json:, template:, changes: json[:changes]).reject! } + + let(:json) { JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, symbolize_names: true)[:result].first } + let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } + let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } + + before do + reject_request_stub + end + + it 'rejects' do + expect(reject).to be_truthy + expect(reject_request_stub).to have_been_requested + end + end + + describe '#userdata' do + subject(:userdata) { described_class.new(json:, template:, changes: json[:changes]).userdata } + + it 'returns the requested data' do + expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'john', last_name: 'oliver', :status_group => :educator}) + end + + context 'with a blank attribute' do + before do + json[:@type] + json[:changes].first[:request][:content][:response][:items].last[:attribute][:value][:value] = ' ' + end + + # The validations of the User model will take care + it 'passes' do + expect { userdata }.not_to raise_error + end + end + + context 'with a missing attribute' do + before do + json[:changes].first[:request][:content][:response][:items].pop + end + + it 'raises an error' do + expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'AffiliationRole must not be empty') + end + end + + context 'with more than one RelationshipChange' do + before do + json[:changes] += json[:changes] + end + + it 'raises an error' do + expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'Relationship should have exactly one RelationshipChange') + end + end + + context 'without any provided attributes' do + before do + json[:changes].first[:request][:content][:response][:items] = nil + end + + it 'raises an error' do + expect { userdata }.to raise_error(Enmeshed::ConnectorError, "Could not parse userdata in relationship change: #{json[:changes].first}") + end + end + end + + describe '#peer' do + subject(:peer) { described_class.new(json:, template:, changes: json[:changes]).peer } + + it 'returns the peer id' do + expect(peer).to eq 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp' + end + end +end diff --git a/spec/lib/enmeshed/relationship_template_spec.rb b/spec/lib/enmeshed/relationship_template_spec.rb index a65682cc7..7919f74e9 100644 --- a/spec/lib/enmeshed/relationship_template_spec.rb +++ b/spec/lib/enmeshed/relationship_template_spec.rb @@ -10,8 +10,78 @@ allow(User).to receive(:omniauth_providers).and_return([:nbp]) end + describe '.create!' do + subject(:new_template) { described_class.create!(nbp_uid: 'example_uid') } + + before do + allow(Enmeshed::Connector).to receive(:create_relationship_template) + .and_return('RelationshipTemplateExampleTruncatedReferenceA==') + end + + it 'sets the truncated reference' do + new_template + expect(Enmeshed::Connector).to have_received(:create_relationship_template) + expect(new_template.truncated_reference).to eq 'RelationshipTemplateExampleTruncatedReferenceA==' + end + end + + describe '.display_name_attribute' do + subject(:display_name_attribute) { described_class.display_name_attribute } + + before do + stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") + .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) + end + + context 'with a cached display name' do + before do + identity_attribute = Enmeshed::Attribute::Identity.new(type: 'DisplayName', value: 'cached_display_name') + identity_attribute.instance_variable_set(:@id, 'cached_id') + described_class.instance_variable_set(:@display_name_attribute, identity_attribute) + end + + after do + described_class.instance_variable_set(:@display_name_attribute, nil) + end + + it 'does not set a new display name id' do + expect(display_name_attribute.id).to eq('cached_id') + end + end + + context 'without a cached display name' do + before do + described_class.instance_variable_set(:@display_name_attribute, nil) + end + + context 'with an existing display name' do + before do + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + .to_return(body: file_fixture('enmeshed/existing_display_name.json')) + end + + it 'returns the id of the existing attribute' do + expect(display_name_attribute.id).to eq 'ATT_id_of_exist_name' + end + end + + context 'with no existing display name' do + before do + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + .to_return(body: file_fixture('enmeshed/no_existing_display_name.json')) + stub_request(:post, "#{connector_api_url}/Attributes") + .to_return(body: file_fixture('enmeshed/display_name_created.json')) + end + + it 'returns the id of a new attribute' do + expect(display_name_attribute.id).to eq 'ATT_id_of_a_new_name' + end + end + end + end + describe '#initialize' do - it 'raises an error if no valid option is given' do + it 'raises an error if neither a truncated reference nor a NBP UID is given' do expect { described_class.new }.to raise_error(ArgumentError) end @@ -33,7 +103,7 @@ before do stub_request(:get, "#{connector_api_url}/RelationshipTemplates?isOwn=true") - .to_return(body: file_fixture('enmeshed/valid_relationship_template_created.json')) + .to_return(body: file_fixture('enmeshed/relationship_template.json')) end it 'populates the object with the given attribute' do @@ -73,25 +143,82 @@ end end - describe '#to_json' do - context 'when certificate requests are enabled' do - subject(:template) { described_class.new(truncated_reference: 'example_truncated_reference') } + describe '#app_store_link' do + subject(:app_store_link) { described_class.new(nbp_uid: 'example_uid').app_store_link } - before do - stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") - .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) + it 'returns the app store link' do + expect(app_store_link).to eq Settings.dig(:omniauth, :nbp, :enmeshed, :app_store_link) + end + end + + describe '#play_store_link' do + subject(:play_store_link) { described_class.new(nbp_uid: 'example_uid').play_store_link } + + it 'returns the app store link' do + expect(play_store_link).to eq Settings.dig(:omniauth, :nbp, :enmeshed, :play_store_link) + end + end + + describe '#qr_code' do + subject(:qr_code) do + described_class.new(truncated_reference: 'RelationshipTemplateExampleTruncatedReferenceA==').qr_code + end + + it 'returns the QR code' do + expect(qr_code).to be_an_instance_of ChunkyPNG::Image + end + end + + describe '#qr_code_path' do + subject(:qr_code_path) do + described_class.new(truncated_reference: 'RelationshipTemplateExampleTruncatedReferenceA==').qr_code_path + end + + it 'returns a link to the platforms qr code view action' do + expect(qr_code_path).to eq '/users/nbp_wallet/qr_code' \ + '?truncated_reference=RelationshipTemplateExampleTruncatedReferenceA%3D%3D' + end + end - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") - .to_return(body: file_fixture('enmeshed/existing_display_name.json')) + describe '#remaining_validity' do + subject(:remaining_validity) { described_class.new(nbp_uid: 'example_uid').remaining_validity } - get_relationship_templates_stub.to_return(body: file_fixture('enmeshed/no_relationship_templates_yet.json')) + it 'returns the remaining time the template is valid' do + expect(remaining_validity).to be_within(1.second).of(12.hours.to_i) + end + end + + describe '#to_json' do + subject(:template) { described_class.new(truncated_reference: 'example_truncated_reference') } + before do + stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") + .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) + + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + .to_return(body: file_fixture('enmeshed/existing_display_name.json')) + end + + context 'when certificate requests are enabled' do + before do allow(Settings).to receive(:dig).with(:omniauth, :nbp, :enmeshed, :allow_certificate_request).and_return(true) end it 'returns the expected JSON' do expect(described_class).to receive(:allow_certificate_request).and_call_original - expect(template.to_json).to include('CreateAttributeRequestItem') + expect(template.to_json).to include('CreateAttributeRequestItem', 'ShareAttributeRequestItem', 'ReadAttributeRequestItem') + end + end + + context 'when certificate requests are not enabled' do + before do + allow(Settings).to receive(:dig).with(:omniauth, :nbp, :enmeshed, :allow_certificate_request).and_return(false) + end + + it 'returns the expected JSON' do + expect(described_class).not_to receive(:allow_certificate_request) + expect(template.to_json).not_to include('CreateAttributeRequestItem') + expect(template.to_json).to include('ShareAttributeRequestItem', 'ReadAttributeRequestItem') end end end From cdd687f387afc2cef0d6b3dabe383d8d1e763152 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Thu, 16 Jan 2025 13:58:50 +0100 Subject: [PATCH 08/18] fix(nbp): add redirect to root when unauthorized on `/users/nbp_wallet/*` Usually, when the current user is not logged in and unauthorized, a redirect to the registration page makes sense. But in case of the `NBPWalletController` actions, the SAML provider and uid are missing and the provided alert message is more meaningful. Part of XI-6523 --- app/controllers/application_controller.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b9d2df0e8..58d11b5af 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -78,8 +78,7 @@ def render_error(message, status) # rubocop:disable Metrics/AbcSize set_sentry_context respond_to do |format| format.any do - # Prevent redirect loop - if request.url == request.referer || request.referer&.match?(new_user_session_path) + if redirect_loop? || unauthorized_nbp_request?(status) redirect_to :root, alert: message elsif current_user.nil? && status == :unauthorized store_location_for(:user, request.fullpath) if current_user.nil? @@ -92,6 +91,14 @@ def render_error(message, status) # rubocop:disable Metrics/AbcSize end end + def redirect_loop? + request.url == request.referer || request.referer&.match?(new_user_session_path) + end + + def unauthorized_nbp_request?(status) + current_user.nil? && status == :unauthorized && instance_of?(Users::NbpWalletController) + end + def mnemosyne_trace yield ensure From 1757caa65a1b0866d88b53582551f0233b569671 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Fri, 17 Jan 2025 14:31:11 +0100 Subject: [PATCH 09/18] test(nbp): add request specs Drop corresponding controller spec while maintaining full test coverage Part of XI-6523 --- .../users/nbp_wallet_controller_spec.rb | 358 ------------------ .../requests/users/nbp_wallet/connect_spec.rb | 97 +++++ .../users/nbp_wallet/finalize_spec.rb | 218 +++++++++++ .../requests/users/nbp_wallet/qr_code_spec.rb | 46 +++ .../nbp_wallet/relationship_status_spec.rb | 69 ++++ spec/support/shared_examples/authorization.rb | 13 + 6 files changed, 443 insertions(+), 358 deletions(-) delete mode 100644 spec/controllers/users/nbp_wallet_controller_spec.rb create mode 100644 spec/requests/users/nbp_wallet/connect_spec.rb create mode 100644 spec/requests/users/nbp_wallet/finalize_spec.rb create mode 100644 spec/requests/users/nbp_wallet/qr_code_spec.rb create mode 100644 spec/requests/users/nbp_wallet/relationship_status_spec.rb create mode 100644 spec/support/shared_examples/authorization.rb diff --git a/spec/controllers/users/nbp_wallet_controller_spec.rb b/spec/controllers/users/nbp_wallet_controller_spec.rb deleted file mode 100644 index 58046a328..000000000 --- a/spec/controllers/users/nbp_wallet_controller_spec.rb +++ /dev/null @@ -1,358 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Users::NbpWalletController do - render_views - - let(:connector_api_url) { "#{Settings.omniauth.nbp.enmeshed.connector_url}/api/v2" } - - before do - stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") - .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) - - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") - .to_return(body: file_fixture('enmeshed/no_existing_display_name.json')) - - stub_request(:post, "#{connector_api_url}/Attributes") - .to_return(body: file_fixture('enmeshed/display_name_created.json')) - - stub_request(:post, "#{connector_api_url}/RelationshipTemplates/Own") - .to_return(body: file_fixture('enmeshed/relationship_template_created.json')) - - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) - - session[:saml_uid] = 'example_uid' - session[:omniauth_provider] = 'nbp' - allow(User).to receive(:omniauth_providers).and_return([:nbp]) - end - - describe 'GET #connect' do - subject(:get_request) { get :connect } - - context 'when the connector is down' do - before { stub_request(:get, "#{connector_api_url}/Relationships?status=Pending").to_timeout } - - it 'redirects to the registration page' do - get_request - expect(response).to redirect_to(new_user_registration_path) - end - end - - context 'without errors' do - context 'when there is a pending Relationship' do - it 'redirects to #finalize' do - get_request - expect(response).to redirect_to(nbp_wallet_finalize_users_path) - end - end - - context 'when there is no pending Relationship' do - before { session[:saml_uid] = 'example_uid_without_pending_relationships' } - - it 'sets the correct template' do - get_request - expect(assigns(:template).truncated_reference).to eq('RelationshipTemplateExampleTruncatedReferenceA==') - end - end - end - - context 'when the display name is cached' do - before do - display_name_attribute = Enmeshed::Attribute::Identity.new(type: 'DisplayName', value: 'cached_display_name') - display_name_attribute.instance_variable_set(:@id, 'cached_id') - Enmeshed::RelationshipTemplate.instance_variable_set(:@display_name_attribute, display_name_attribute) - - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/no_relationships_yet.json')) - end - - it 'does not set a new display name id' do - get_request - expect(Enmeshed::RelationshipTemplate.display_name_attribute.id).to eq('cached_id') - end - end - - context 'when no display name is cached' do - before do - Enmeshed::RelationshipTemplate.instance_variable_set(:@display_name_attribute, nil) - - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/no_relationships_yet.json')) - end - - context 'when a display name exists' do - before do - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") - .to_return(body: file_fixture('enmeshed/existing_display_name.json')) - end - - it 'sets the display name id to the existing one' do - get_request - expect(Enmeshed::RelationshipTemplate.display_name_attribute.id).to eq('ATT_id_of_exist_name') - end - end - - context 'when no display name exists' do - before do - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") - .to_return(body: file_fixture('enmeshed/no_existing_display_name.json')) - end - - it 'creates a new display name' do - get_request - expect(Enmeshed::RelationshipTemplate.display_name_attribute.id).to eq('ATT_id_of_a_new_name') - end - end - end - end - - describe 'GET #relationship_status' do - subject(:get_request) { get :relationship_status } - - before { session[:relationship_template_id] = 'RLT_example_id_ABCXY' } - - context 'when the connector is down' do - before { stub_request(:get, "#{connector_api_url}/Relationships?status=Pending").to_timeout } - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to(nbp_wallet_connect_users_path) - end - end - - context 'when no relationship has been created yet' do - before do - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/no_relationships_yet.json')) - end - - it 'returns a json with the waiting status' do - get_request - expect(response.parsed_body['status']).to eq 'waiting' - end - end - - context 'when the relationship is expired' do - let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } - - before do - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/relationship_expired.json')) - - reject_request_stub - end - - it 'returns a json with the waiting status' do - get_request - expect(response.parsed_body['status']).to eq 'waiting' - end - - it 'tries to reject the RelationshipChange' do - get_request - expect(reject_request_stub).to have_been_requested - end - end - - context 'when the relationship is valid' do - let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } - - before do - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) - - reject_request_stub - end - - it 'returns a json with the ready status' do - get_request - expect(response.parsed_body['status']).to eq 'ready' - end - - it 'does not reject the RelationshipChange' do - get_request - expect(reject_request_stub).not_to have_been_requested - end - end - end - - describe 'GET #qr_code' do - subject(:get_request) { get :qr_code, params: {truncated_reference:} } - - let(:truncated_reference) { 'example_truncated_reference' } - let(:qr_code) { RQRCode::QRCode.new("nmshd://tr##{truncated_reference}").as_png(border_modules: 0) } - - it 'returns a png image' do - get_request - expect(response.content_type).to eq 'image/png' - end - - it 'initializes a RelationshipTemplate' do - expect(Enmeshed::RelationshipTemplate).to receive(:new).with(truncated_reference:).and_call_original - get_request - end - - it 'sends the qr code' do - get_request - expect(response.body).to eq qr_code.to_s - end - end - - describe 'GET #finalize' do - subject(:get_request) { get :finalize } - - let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } - let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Accept") } - - before do - session[:relationship_template_id] = 'RLT_example_id_ABCXY' - - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) - stub_request(:get, "#{connector_api_url}/Relationships") - .to_return(body: file_fixture('enmeshed/valid_relationship_created.json')) - - accept_request_stub - reject_request_stub - end - - context 'when the connector is down' do - before { stub_request(:get, "#{connector_api_url}/Relationships?status=Pending").to_timeout } - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to(nbp_wallet_connect_users_path) - end - end - - context 'without errors' do - it 'accepts the RelationshipChange' do - get_request - expect(accept_request_stub).to have_been_requested - end - - it 'creates a user' do - expect { get_request }.to change(User, :count).by(1) - end - - it 'creates two UserIdentities' do - expect { get_request }.to change(UserIdentity, :count).by(2) - end - - it 'sends a confirmation mail' do - expect { get_request }.to change(ActionMailer::Base.deliveries, :count).by(1) - end - - it 'does not create a confirmed user' do - get_request - expect(User.order(:created_at).last).not_to be_confirmed - end - end - - context 'when the user cannot be created' do - before do - create(:user, email: 'already_taken@problem.eu') - - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/relationship_impossible_attributes.json')) - end - - it 'rejects the RelationshipChange' do - get_request - expect(reject_request_stub).to have_been_requested - end - - it 'does not create a user' do - expect { get_request }.not_to change(User, :count) - end - - it 'creates no UserIdentities' do - expect { get_request }.not_to change(UserIdentity, :count) - end - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to nbp_wallet_connect_users_path - end - - it 'does not send a confirmation mail' do - expect { get_request }.not_to change(ActionMailer::Base, :deliveries) - end - end - - context 'when the RelationshipChange cannot be accepted' do - before { accept_request_stub.to_return(status: 500) } - - it 'does not create a user' do - expect { get_request }.not_to change(User, :count) - end - - it 'creates no UserIdentities' do - expect { get_request }.not_to change(UserIdentity, :count) - end - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to nbp_wallet_connect_users_path - end - - it 'does not send a confirmation mail' do - expect { get_request }.not_to change(ActionMailer::Base, :deliveries) - end - - context 'when the RelationshipChange cannot be rejected either' do - before { reject_request_stub.to_timeout } - - it 'does not create a user' do - expect { get_request }.not_to change(User, :count) - end - - it 'creates no UserIdentities' do - expect { get_request }.not_to change(UserIdentity, :count) - end - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to nbp_wallet_connect_users_path - end - - it 'does not send a confirmation mail' do - expect { get_request }.not_to change(ActionMailer::Base, :deliveries) - end - end - end - - context 'when an invalid role is provided' do - before do - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/invalid_role_relationship.json')) - end - - it 'does not create a user' do - expect { get_request }.not_to change(User, :count) - end - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to nbp_wallet_connect_users_path - end - end - - context 'when an attribute is missing' do - before do - stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") - .to_return(body: file_fixture('enmeshed/missing_attribute_relationship.json')) - end - - it 'does not create a user' do - expect { get_request }.not_to change(User, :count) - end - - it 'redirects to the connect page' do - get_request - expect(response).to redirect_to nbp_wallet_connect_users_path - end - end - end -end diff --git a/spec/requests/users/nbp_wallet/connect_spec.rb b/spec/requests/users/nbp_wallet/connect_spec.rb new file mode 100644 index 000000000..3a2b44ad8 --- /dev/null +++ b/spec/requests/users/nbp_wallet/connect_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::NBPWallet::Connect' do + subject(:connect_request) { get '/users/nbp_wallet/connect' } + + let(:uid) { 'example-uid' } + let(:session_params) { {saml_uid: uid, omniauth_provider: 'nbp'} } + + before do + set_session(session_params) + end + + context 'without errors' do + context 'with a pending Relationship' do + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(Enmeshed::Relationship) + end + + it 'redirects to #finalize' do + connect_request + expect(response).to redirect_to nbp_wallet_finalize_users_path + end + end + + context 'without a pending Relationship' do + let(:truncated_reference) { 'RelationshipTemplateExampleTruncatedReferenceA==' } + let(:relationship_template) do + instance_double(Enmeshed::RelationshipTemplate, + expires_at: 12.hours.from_now, + nbp_uid: uid, + truncated_reference:, + url: "nmshd://tr##{truncated_reference}", + qr_code_path: nbp_wallet_qr_code_users_path(truncated_reference:), + remaining_validity: 12.hours.from_now - Time.zone.now, + app_store_link: Settings.dig(:omniauth, :nbp, :enmeshed, :app_store_link), + play_store_link: Settings.dig(:omniauth, :nbp, :enmeshed, :play_store_link)) + end + + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return([]) + allow(Enmeshed::RelationshipTemplate).to receive(:create!).with(nbp_uid: uid).and_return(relationship_template) + end + + it 'sets the correct template' do + connect_request + expect(response.body).to include 'RelationshipTemplateExampleTruncatedReferenceA==' + end + end + end + + context 'with errors' do + # `Enmeshed::ConnectorError` is unknown until 'lib/enmeshed/connector.rb' is loaded, because it's defined there + require 'enmeshed/connector' + + shared_examples 'an erroneous request' do |error_type| + it 'passes the error reason to Sentry' do + expect(Sentry).to receive(:capture_exception) do |e| + expect(e).to be_a error_type + end + connect_request + end + + it 'redirects to #new_user_registration' do + expect(connect_request).to redirect_to new_user_registration_path + end + + it 'displays an error message' do + connect_request + expect(flash[:alert]).to include I18n.t('common.errors.generic_try_later') + end + end + + context 'without the session' do + let(:session_params) { {} } + + before do + connect_request + end + + it_behaves_like 'an unauthorized request' + end + + context 'when the connector is down' do + before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } + + it_behaves_like 'an erroneous request', Faraday::Error + end + + context 'with an error when parsing the connector response' do + before { allow(Enmeshed::Connector).to receive(:pending_relationships).and_raise(Enmeshed::ConnectorError) } + + it_behaves_like 'an erroneous request', Enmeshed::ConnectorError + end + end +end diff --git a/spec/requests/users/nbp_wallet/finalize_spec.rb b/spec/requests/users/nbp_wallet/finalize_spec.rb new file mode 100644 index 000000000..adb85073d --- /dev/null +++ b/spec/requests/users/nbp_wallet/finalize_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::NBPWallet::Finalize' do + subject(:finalize_request) { get '/users/nbp_wallet/finalize' } + + let(:uid) { 'example-uid' } + let(:session_params) { {saml_uid: uid, omniauth_provider: 'nbp'} } + + before do + set_session(session_params) + end + + context 'without any errors' do + let(:relationship) do + instance_double(Enmeshed::Relationship, + accept!: true, + userdata: { + email: 'john.oliver@example103.org', + first_name: 'john', + last_name: 'oliver', + status_group: 'learner', + }) + end + + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + end + + it 'creates a user' do + expect { finalize_request }.to change(User, :count).by(1) + end + + it 'creates two UserIdentities' do + expect { finalize_request }.to change(UserIdentity, :count).by(2) + end + + it 'accepts the Relationship' do + expect(relationship).to receive(:accept!) + finalize_request + end + + it 'sends a confirmation mail' do + expect { finalize_request }.to change(ActionMailer::Base.deliveries, :count).by(1) + end + + it 'does not create a confirmed user' do + finalize_request + expect(User.order(:created_at).last).not_to be_confirmed + end + + it 'asks the user to verify the email address' do + finalize_request + expect(response).to redirect_to home_index_path + expect(flash[:notice]).to include I18n.t('devise.registrations.signed_up_but_unconfirmed') + end + end + + context 'with errors' do + shared_examples 'a handled erroneous request' do |error_message| + it 'does not create a user' do + expect { finalize_request }.not_to change(User, :count) + end + + it 'creates no UserIdentities' do + expect { finalize_request }.not_to change(UserIdentity, :count) + end + + it 'redirects to the connect page' do + finalize_request + expect(response).to redirect_to nbp_wallet_connect_users_path + end + + it 'displays an error message' do + finalize_request + expect(flash[:alert]).to eq error_message + end + + it 'does not send a confirmation mail' do + expect { finalize_request }.not_to change(ActionMailer::Base, :deliveries) + end + end + + shared_examples 'a documented erroneous request' do |error| + it 'passes the error reason to Sentry' do + expect(Sentry).to receive(:capture_exception) do |e| + expect(e).to be_a error + end + finalize_request + end + end + + context 'without the session' do + let(:session_params) { {} } + + before do + finalize_request + end + + it_behaves_like 'an unauthorized request' + end + + context 'when the connector is down' do + before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } + + it 'redirects to the connect page' do + finalize_request + expect(response).to redirect_to(nbp_wallet_connect_users_path) + end + end + + context 'when an attribute is missing' do + # `Enmeshed::ConnectorError` is unknown until 'lib/enmeshed/connector.rb' is loaded, because it's defined there + require 'enmeshed/connector' + + let(:relationship) do + instance_double(Enmeshed::Relationship, accept!: false, reject!: true) + end + + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive(:userdata).and_raise(Enmeshed::ConnectorError, 'EMailAddress must not be empty') + end + + it_behaves_like 'a handled erroneous request', I18n.t('common.errors.generic') + it_behaves_like 'a documented erroneous request', Enmeshed::ConnectorError + end + + context 'with an invalid status group' do + let(:relationship) do + instance_double(Enmeshed::Relationship, + reject!: true, + userdata: { + email: 'john.oliver@example103.org', + first_name: 'john', + last_name: 'oliver', + status_group: nil, + }) + end + + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + end + + it_behaves_like 'a handled erroneous request', 'Could not create User: Unknown role. Please select either ' \ + '"Teacher" or "Student" as your role.' + end + + context 'when the User cannot be saved' do + let(:relationship) do + instance_double(Enmeshed::Relationship, + reject!: true, + userdata: { + email: 'john.oliver@example103.org', + first_name: 'john', + last_name: 'oliver', + status_group: 'learner', + }) + end + + before do + create(:user, email: relationship.userdata[:email]) + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + end + + it_behaves_like 'a handled erroneous request', 'Could not create User: Email has already been taken' + end + + context 'when the RelationshipChange cannot be accepted' do + let(:relationship) do + instance_double(Enmeshed::Relationship, + accept!: false, + reject!: true, + userdata: { + email: 'john.oliver@example103.org', + first_name: 'john', + last_name: 'oliver', + status_group: 'learner', + }) + end + + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + end + + it_behaves_like 'a handled erroneous request', I18n.t('common.errors.generic') + + it 'rejects the Relationship' do + expect(relationship).to receive(:reject!) + finalize_request + end + + context 'when the RelationshipChange cannot be rejected either' do + before { allow(relationship).to receive(:reject!).and_raise(Faraday::ConnectionFailed) } + + it_behaves_like 'a handled erroneous request', I18n.t('common.errors.generic') + it_behaves_like 'a documented erroneous request', Faraday::ConnectionFailed + + it 'does not create a user' do + expect { finalize_request }.not_to change(User, :count) + end + + it 'creates no UserIdentities' do + expect { finalize_request }.not_to change(UserIdentity, :count) + end + + it 'does not try to reject the Relationship again' do + expect(relationship).to receive(:reject!).once + finalize_request + end + end + end + end +end diff --git a/spec/requests/users/nbp_wallet/qr_code_spec.rb b/spec/requests/users/nbp_wallet/qr_code_spec.rb new file mode 100644 index 000000000..6afb92ab1 --- /dev/null +++ b/spec/requests/users/nbp_wallet/qr_code_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::NBPWallet::QRCode' do + subject(:qr_code_request) { get '/users/nbp_wallet/qr_code', params: {truncated_reference:} } + + let(:uid) { 'example-uid' } + let(:session_params) { {saml_uid: uid, omniauth_provider: 'nbp'} } + + let(:truncated_reference) { 'example_truncated_reference' } + let(:qr_code) { RQRCode::QRCode.new("nmshd://tr##{truncated_reference}").as_png(border_modules: 0) } + let(:relationship_template) do + instance_double(Enmeshed::RelationshipTemplate, qr_code:) + end + + before do + set_session(session_params) + allow(Enmeshed::RelationshipTemplate).to receive(:new).with(truncated_reference:).and_return(relationship_template) + end + + it 'returns a png image' do + qr_code_request + expect(response.content_type).to eq 'image/png' + end + + it 'initializes a RelationshipTemplate' do + expect(Enmeshed::RelationshipTemplate).to receive(:new) + qr_code_request + end + + it 'sends the qr code' do + qr_code_request + expect(response.body).to eq qr_code.to_s + end + + context 'without the session' do + let(:session_params) { {} } + + before do + qr_code_request + end + + it_behaves_like 'an unauthorized request' + end +end diff --git a/spec/requests/users/nbp_wallet/relationship_status_spec.rb b/spec/requests/users/nbp_wallet/relationship_status_spec.rb new file mode 100644 index 000000000..f3e6ccdaf --- /dev/null +++ b/spec/requests/users/nbp_wallet/relationship_status_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::NBPWallet::RelationshipStatus' do + subject(:relationship_status_request) { get '/users/nbp_wallet/relationship_status' } + + let(:uid) { 'example-uid' } + let(:session_params) { {saml_uid: uid, omniauth_provider: 'nbp'} } + + before do + set_session(session_params) + end + + context 'without errors' do + context 'with a Relationship' do + before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(Enmeshed::Relationship) } + + it 'returns a json with the ready status' do + relationship_status_request + expect(response.parsed_body['status']).to eq 'ready' + end + end + + context 'without a Relationship' do + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return([]) + end + + it 'returns a json with the waiting status' do + relationship_status_request + expect(response.parsed_body['status']).to eq 'waiting' + end + end + end + + context 'with errors' do + # `Enmeshed::ConnectorError` is unknown until 'lib/enmeshed/connector.rb' is loaded, because it's defined there + require 'enmeshed/connector' + + context 'without the session' do + let(:session_params) { {} } + + before do + relationship_status_request + end + + it_behaves_like 'an unauthorized request' + end + + context 'when the connector is down' do + before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } + + it 'redirects to the connect page' do + relationship_status_request + expect(response).to redirect_to(nbp_wallet_connect_users_path) + end + end + + context 'with an error when parsing the connector response' do + before { allow(Enmeshed::Connector).to receive(:pending_relationships).and_raise(Enmeshed::ConnectorError) } + + it 'redirects to the connect page' do + relationship_status_request + expect(response).to redirect_to(nbp_wallet_connect_users_path) + end + end + end +end diff --git a/spec/support/shared_examples/authorization.rb b/spec/support/shared_examples/authorization.rb new file mode 100644 index 000000000..f5be7712c --- /dev/null +++ b/spec/support/shared_examples/authorization.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.shared_examples 'an unauthorized request' do + it 'redirects to root' do + expect(response).to redirect_to root_path + end + + it 'displays an error message' do + expect(flash[:alert]).to include I18n.t('common.errors.not_authorized') + end +end From 8ac6c06471a148fcc3670f961089b6fce59fe64d Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Mon, 20 Jan 2025 15:47:35 +0100 Subject: [PATCH 10/18] feat(enmeshed): support nmshd::Connector API version 3.1.0 Main changes: - Relationship: Adapt parsing of the userdata to new schema Values to the requested attributes are now passed in `creationContent/response/items` (former: `changes/request/response/items`). - Connector: Adapt accepting/rejecting a Relationship to new endpoints `/api/v2/Relationships/#{relationship_id}/Changes/#{change_id}/#{action}` was dropped in favor of `/api/v2/Relationships/#{relationship_id}/Accept` (and `Reject`) Part of XI-6523 --- lib/enmeshed/api_schema.yml | 1553 +++++++++++++---- lib/enmeshed/attribute.rb | 11 + lib/enmeshed/connector.rb | 23 +- lib/enmeshed/object.rb | 6 +- lib/enmeshed/relationship.rb | 31 +- .../files/enmeshed/display_name_created.json | 2 +- .../files/enmeshed/existing_display_name.json | 4 +- .../files/enmeshed/get_enmeshed_address.json | 2 +- .../enmeshed/invalid_role_relationship.json | 156 -- .../missing_attribute_relationship.json | 143 -- .../files/enmeshed/relationship_expired.json | 140 +- .../relationship_impossible_attributes.json | 156 -- .../files/enmeshed/relationship_template.json | 4 +- .../enmeshed/valid_relationship_created.json | 157 +- spec/lib/enmeshed/attribute_spec.rb | 16 + spec/lib/enmeshed/connector_spec.rb | 44 +- spec/lib/enmeshed/relationship_spec.rb | 65 +- .../enmeshed/relationship_template_spec.rb | 11 +- 18 files changed, 1441 insertions(+), 1083 deletions(-) delete mode 100644 spec/fixtures/files/enmeshed/invalid_role_relationship.json delete mode 100644 spec/fixtures/files/enmeshed/missing_attribute_relationship.json delete mode 100644 spec/fixtures/files/enmeshed/relationship_impossible_attributes.json diff --git a/lib/enmeshed/api_schema.yml b/lib/enmeshed/api_schema.yml index fe0fa0fee..6175fcc40 100644 --- a/lib/enmeshed/api_schema.yml +++ b/lib/enmeshed/api_schema.yml @@ -1,6 +1,7 @@ -# https://github.com/nmshd/connector/blob/746dc3d6fa7dddf224053a8da0fef5a3248474da/src/modules/coreHttpApi/openapi.yml +# https://github.com/nmshd/connector/blob/main/src/modules/coreHttpApi/openapi.yml +# current version: https://github.com/nmshd/connector/blob/6.14.3/src/modules/coreHttpApi/openapi.yml -openapi: 3.0.3 +openapi: 3.1.0 servers: - url: / @@ -20,7 +21,7 @@ info: name: MIT url: https://raw.githubusercontent.com/nmshd/connector/main/LICENSE contact: - name: j&s-soft GmbH + name: j&s-soft AG email: info@js-soft.com url: https://www.js-soft.com/ @@ -240,9 +241,51 @@ paths: Syncs the Connector messages and relationships with the Backbone. Checks for new relationships as well as incoming changes of existing ones. Checks for new or updated Messages. - Returns all affected relationships and messages. tags: - Account + responses: + 204: + description: No content. + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + + # ------------------- Attributes ------------------- + + /api/v2/Attributes/CanCreate: + put: + operationId: canCreateRepositoryAttribute + description: "Checks if a Repository Attribute can be created with the given parameters." + tags: + - Attributes + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + content: + type: object + additionalProperties: false + required: + - value + properties: + value: + $ref: "#/components/schemas/AttributeValue" + tags: + $ref: "#/components/schemas/IdentityAttributeContent_tags" + validFrom: + $ref: "#/components/schemas/AttributeContent_validFrom" + validTo: + $ref: "#/components/schemas/AttributeContent_validTo" responses: 200: description: Success @@ -252,19 +295,8 @@ paths: type: object properties: result: - type: object - properties: - relationships: - type: array - items: - $ref: "#/components/schemas/Relationship" - messages: - type: array - items: - $ref: "#/components/schemas/Message" - required: - - messages - - relationships + nullable: false + $ref: "#/components/schemas/CanCreateRepositoryAttributeResponse" required: - result headers: @@ -279,8 +311,6 @@ paths: 403: $ref: "#/components/responses/Forbidden" - # ------------------- Attributes ------------------- - /api/v2/Attributes: post: operationId: createRepositoryAttribute @@ -299,11 +329,6 @@ paths: required: - value properties: - "@type": - type: string - enum: ["IdentityAttribute"] - owner: - $ref: "#/components/schemas/Address" value: $ref: "#/components/schemas/AttributeValue" tags: @@ -798,7 +823,7 @@ paths: /api/v2/Attributes/{id}/Versions/Shared: get: - operationId: getSharedVersionsOfRepositoryAttribute + operationId: getSharedVersionsOfAttribute tags: - Attributes parameters: @@ -1030,7 +1055,7 @@ paths: $ref: "#/components/responses/NotFound" delete: operationId: deleteRepositoryAttribute - description: Delete an repository attribute. + description: Delete a repository attribute. tags: - Attributes parameters: @@ -1041,9 +1066,8 @@ paths: schema: $ref: "#/components/schemas/AttributeID" responses: - 200: - description: | - Success. Deleting an repository attribute. + 204: + description: Success. Deleting a repository attribute. headers: X-Response-Duration-ms: schema: @@ -1158,21 +1182,21 @@ paths: /api/v2/Attributes/ThirdParty/{id}: delete: - operationId: deleteThirdPartyOwnedRelationshipAttributeAndNotifyPeer - description: Delete an third party relationship attribute and notify the owner. + operationId: deleteThirdPartyRelationshipAttributeAndNotifyPeer + description: Delete a ThirdPartyRelationshipAttribute and notify the peer. tags: - Attributes parameters: - in: path name: id - description: The ID of the third party relationship attribute to delete. + description: The ID of the ThirdPartyRelationshipAttribute to delete. required: true schema: $ref: "#/components/schemas/AttributeID" responses: 200: description: | - Success. Deleting an third party relationship attribute and notifying the owner returns the notificationId of the send notification. + Success. Deleting a ThirdPartyRelationshipAttribute and notifying the peer returns the NotificationId of the sent Notification. content: application/json: schema: @@ -1298,6 +1322,38 @@ paths: 403: $ref: "#/components/responses/Forbidden" + /api/v2/Attributes/TagCollection: + get: + operationId: getAttributeTagCollection + description: List valid Tags for Attributes + tags: + - Attributes + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + $ref: "#/components/schemas/AttributeTagCollection" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + /api/v2/Attributes/ExecuteIdentityAttributeQuery: post: operationId: executeIdentityQuery @@ -1827,15 +1883,13 @@ paths: type: string format: date-time description: A timestamp that describes when this file will expire. - example: 2023-01-01 + example: 2025-01-01 nullable: false file: type: string format: binary nullable: false required: - - title - - expiresAt - file responses: 201: @@ -1931,34 +1985,26 @@ paths: /api/v2/Files/Peer: post: operationId: loadPeerFile - description: Loads a file of another identity. After it is loaded once, you can retrieve it without the need for the secret key by calling one of the GET-routes. + description: Loads a file of another identity. After it is loaded once, you can retrieve it without the need for the truncatedReference by calling one of the GET-routes. tags: - Files requestBody: content: application/json: schema: - oneOf: - - $ref: "#/components/schemas/FileReference" - - $ref: "#/components/schemas/FileReferenceTruncated" - - $ref: "#/components/schemas/TokenReferenceTruncated" - examples: - FileReference: - value: | - { - "id": "FIL_________________", - "secretKey": "YOUR_SECRET_KEY" - } - FileReferenceTruncated: - value: | - { - "reference": "YOUR_REFERENCE" - } - TokenReferenceTruncated: - value: | - { - "reference": "YOUR_REFERENCE" - } + type: object + properties: + reference: + type: string + format: byte + nullable: false + description: The base64 encoded truncated reference of the File, alternatively of a Token for the File. It consists of all information to get and decrypt it. + password: + type: string + nullable: true + description: The password to load the File. Only required when loading with a password-protected Token. + required: + - reference responses: 201: description: Success @@ -2054,7 +2100,7 @@ paths: get: operationId: getFileMetadata description: | - Gets metadata for the file with the given `idOrReference` when the accept header is set to `application/json` or a QrCode containing the reference to the file if the accept header it set to `image/png`. + Gets metadata for the file with the given `idOrReference` when the accept header is set to `application/json` or a QR Code containing the reference to the file if the accept header it set to `image/png`. `idOrReference` can either be a FileId (starting with `FIL`) or a FileReference (starting with `RklM`). tags: @@ -2140,7 +2186,7 @@ paths: /api/v2/Files/{id}/Token: post: operationId: createTokenForFile - description: Creates a `Token` for the `File` with the given `id`. + description: Creates a `Token` for the `File` with the given `id`. If the accept header is set to `image/png` instead of `application/json`, a QR Code containing the reference to the token is shown. tags: - Files parameters: @@ -2161,8 +2207,16 @@ paths: type: string format: date-time ephemeral: - description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the id and secretKey of the token. Defaults to true. Will be ignored if Accept is set to image/png. + description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the truncatedReference of the token. Defaults to true. Will be ignored if the accept header is set to `image/png`. type: boolean + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this Token. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that will be required to load this Token and information about the password. responses: 201: description: Success @@ -2195,6 +2249,131 @@ paths: 404: $ref: "#/components/responses/NotFound" + # ------------------- IdentityMetadata ------------------- + + /api/v2/IdentityMetadata: + put: + operationId: upsertIdentityMetadata + description: Creates or updates an IdentityMetadata object for the specified `reference` and `key` combination. + tags: + - IdentityMetadata + requestBody: + content: + application/json: + schema: + type: object + $ref: "#/components/schemas/UpsertIdentityMetadataRequest" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/IdentityMetadata" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + get: + operationId: getIdentityMetadata + description: Fetches the IdentityMetadata with the given `reference` and `key` combination. + tags: + - IdentityMetadata + parameters: + - in: query + name: reference + description: The reference of the IdentityMetadata. + required: true + schema: + $ref: "#/components/schemas/Address" + - in: query + name: key + description: The optional key of the IdentityMetadata. + required: false + schema: + type: string + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/IdentityMetadata" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + delete: + operationId: deleteIdentityMetadata + description: Deletes the IdentityMetadata with the given `reference` and `key` combination. + tags: + - IdentityMetadata + parameters: + - in: query + name: reference + description: The reference of the IdentityMetadata. + required: true + schema: + $ref: "#/components/schemas/Address" + - in: query + name: key + description: The optional key of the IdentityMetadata. + required: false + schema: + type: string + responses: + 204: + description: Success + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + # ------------------- Messages ------------------- /api/v2/Messages: @@ -2218,15 +2397,20 @@ paths: items: $ref: "#/components/schemas/Address" content: - allOf: - - $ref: "#/components/schemas/MessageContent" + anyOf: + - $ref: "#/components/schemas/Mail" + - $ref: "#/components/schemas/Request" + - $ref: "#/components/schemas/ResponseWrapper" + - $ref: "#/components/schemas/Notification" + - $ref: "#/components/schemas/ArbitraryMessageContent" nullable: false example: "@type": Mail - to: [id_________________________________] - cc: [id_________________________________] + to: [did:e::dids:<22-characters>] + cc: [did:e::dids:<22-characters>] subject: Subject body: Body + description: Either a Mail, a Request, a ResponseWrapper, a Notification or an ArbitraryMessageContent must be provided as the `content` of the Message. attachments: type: array nullable: true @@ -2467,6 +2651,64 @@ paths: # ------------------- Relationships ------------------- + /api/v2/Relationships/CanCreate: + put: + operationId: canCreateRelationship + description: Checks if a Relationship can be created with a given RelationshipTemplate to its creator. Optionally, the potential `creationContent` of the Relationship can also be validated. + tags: + - Relationships + requestBody: + content: + application/json: + schema: + type: object + properties: + templateId: + allOf: + - $ref: "#/components/schemas/RelationshipTemplateID" + nullable: false + creationContent: + oneOf: + - $ref: "#/components/schemas/RelationshipCreationContent" + - $ref: "#/components/schemas/ArbitraryRelationshipCreationContent" + nullable: false + example: + "@type": "ArbitraryRelationshipCreationContent" + value: + prop1: value + prop2: 1 + description: Either a RelationshipCreationContent or an ArbitraryRelationshipCreationContent must be provided as the `creationContent` of the Relationship. + required: + - templateId + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/CanCreateRelationshipResponse" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + /api/v2/Relationships: post: operationId: createRelationship @@ -2483,16 +2725,20 @@ paths: allOf: - $ref: "#/components/schemas/RelationshipTemplateID" nullable: false - content: - allOf: - - $ref: "#/components/schemas/RelationshipChangeRequestContent" + creationContent: + oneOf: + - $ref: "#/components/schemas/RelationshipCreationContent" + - $ref: "#/components/schemas/ArbitraryRelationshipCreationContent" nullable: false example: - prop1: value - prop2: 1 + "@type": "ArbitraryRelationshipCreationContent" + value: + prop1: value + prop2: 1 + description: Either a RelationshipCreationContent or an ArbitraryRelationshipCreationContent must be provided as the `creationContent` of the Relationship. required: - templateId - - content + - creationContent responses: 201: description: Success @@ -2602,26 +2848,57 @@ paths: 403: $ref: "#/components/responses/Forbidden" - /api/v2/Relationships/{id}/Attributes: - get: - operationId: getAttributesForRelationship - description: Queries attributes that are related to the given relationship. + delete: + operationId: decomposeRelationship + description: Decompose a Relationship that has already been terminated. This action will remove the Relationship and all related data. tags: - Relationships parameters: - in: path name: id - description: The ID of the relationship. + description: The ID of the terminated Relationship to decompose. required: true schema: $ref: "#/components/schemas/RelationshipID" responses: - 200: + 204: description: Success - content: - application/json: + headers: + X-Response-Duration-ms: schema: - type: object + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Attributes: + get: + operationId: getAttributesForRelationship + description: Queries attributes that are related to the given relationship. + tags: + - Relationships + parameters: + - in: path + name: id + description: The ID of the relationship. + required: true + schema: + $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object properties: result: nullable: false @@ -2642,39 +2919,60 @@ paths: 403: $ref: "#/components/responses/Forbidden" - /api/v2/Relationships/{id}/Changes/{changeId}/Accept: + /api/v2/Relationships/{id}/Accept: put: - operationId: acceptRelationshipChange - description: Accepts the change with the given `changeId`. If the change exists but belongs to another relationship, this call will fail and return status 404. + operationId: acceptRelationship + description: Accepts the pending Relationship. tags: - Relationships parameters: - in: path name: id - description: The ID of the relationship which contains the change. + description: The ID of the relationship. required: true schema: $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Reject: + put: + operationId: rejectRelationship + description: Rejects the pending Relationship. + tags: + - Relationships + parameters: - in: path - name: changeId - description: The ID of the change to accept. + name: id + description: The ID of the relationship. required: true schema: - $ref: "#/components/schemas/RelationshipChangeID" - requestBody: - content: - application/json: - schema: - type: object - properties: - content: - allOf: - - $ref: "#/components/schemas/RelationshipChangeResponseContent" - example: - prop1: value - prop2: 1 - required: - - content + $ref: "#/components/schemas/RelationshipID" responses: 200: description: Success @@ -2684,6 +2982,7 @@ paths: type: object properties: result: + nullable: false $ref: "#/components/schemas/Relationship" required: - result @@ -2703,39 +3002,229 @@ paths: 404: $ref: "#/components/responses/NotFound" - /api/v2/Relationships/{id}/Changes/{changeId}/Reject: + /api/v2/Relationships/{id}/Revoke: put: - operationId: rejectRelationshipChange - description: Rejects the change with the given `changeId`. If the change exists but belongs to another relationship, this call will fail and return status 404. + operationId: revokeRelationship + description: Revoke the pending relationship. If the relationship was created by another identity the request will return with the error code 500. tags: - Relationships parameters: - in: path name: id - description: The ID of the relationship which contains the change. + description: The ID of the relationship. required: true schema: $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Terminate: + put: + operationId: terminateRelationship + description: Terminate the relationship. + tags: + - Relationships + parameters: - in: path - name: changeId - description: The ID of the change to reject. + name: id + description: The ID of the relationship to terminate. required: true schema: - $ref: "#/components/schemas/RelationshipChangeID" - requestBody: - content: - application/json: - schema: - type: object - properties: - content: - allOf: - - $ref: "#/components/schemas/RelationshipChangeResponseContent" - example: - prop1: value - prop2: 1 - required: - - content + $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Reactivate: + put: + operationId: requestRelationshipReactivation + description: Request the relationship reactivation. + tags: + - Relationships + parameters: + - in: path + name: id + description: The ID of the relationship to request reactivation of. + required: true + schema: + $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Reactivate/Accept: + put: + operationId: acceptRelationshipReactivation + description: Accept a relationship reactivation. + tags: + - Relationships + parameters: + - in: path + name: id + description: The ID of the relationship to accept the reactivation of. + required: true + schema: + $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Reactivate/Reject: + put: + operationId: rejectRelationshipReactivation + description: Reject a relationship reactivation. + tags: + - Relationships + parameters: + - in: path + name: id + description: The ID of the relationship to reject the reactivation of. + required: true + schema: + $ref: "#/components/schemas/RelationshipID" + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + properties: + result: + nullable: false + $ref: "#/components/schemas/Relationship" + required: + - result + headers: + X-Response-Duration-ms: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" + X-Response-Time: + schema: + $ref: "#/components/schemas/HeaderContent_X-Response-Time" + 400: + $ref: "#/components/responses/BadRequest" + 401: + $ref: "#/components/responses/Unauthorized" + 403: + $ref: "#/components/responses/Forbidden" + 404: + $ref: "#/components/responses/NotFound" + + /api/v2/Relationships/{id}/Reactivate/Revoke: + put: + operationId: revokeRelationshipReactivation + description: Revoke a relationship reactivation. + tags: + - Relationships + parameters: + - in: path + name: id + description: The ID of the relationship to revoke the reactivation of. + required: true + schema: + $ref: "#/components/schemas/RelationshipID" responses: 200: description: Success @@ -2794,6 +3283,22 @@ paths: name: maxNumberOfAllocations schema: $ref: "#/components/schemas/NumberFilter" + - in: query + name: forIdentity + schema: + $ref: "#/components/schemas/IdFilter" + - in: query + name: passwordProtection + schema: + $ref: "#/components/schemas/ExistenceFilter" + - in: query + name: passwordProtection.password + schema: + $ref: "#/components/schemas/TextFilter" + - in: query + name: passwordProtection.passwordIsPin + schema: + $ref: "#/components/schemas/PartialBooleanFilter" - in: query name: isOwn schema: @@ -2846,15 +3351,27 @@ paths: expiresAt: type: string format: date-time - example: 2023-01-01 - description: A timestamp that describes when this relationship template expires. Expired templates cannot be used to create relationship requests anymore. + example: 2025-01-01 + description: A timestamp that describes when this relationship template expires. Expired templates cannot be used to create relationships anymore. + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this RelationshipTemplate. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that will be required to load this RelationshipTemplate and information about the password. content: - allOf: + oneOf: - $ref: "#/components/schemas/RelationshipTemplateContent" + - $ref: "#/components/schemas/ArbitraryRelationshipTemplateContent" nullable: false example: - prop1: value - prop2: 1 + "@type": "ArbitraryRelationshipTemplateContent" + value: + prop1: "value" + prop2: 1 + description: Either a RelationshipTemplateContent or an ArbitraryRelationshipTemplateContent must be provided as the `content` of the RelationshipTemplate. required: - expiresAt - content @@ -2907,6 +3424,22 @@ paths: name: maxNumberOfAllocations schema: $ref: "#/components/schemas/NumberFilter" + - in: query + name: forIdentity + schema: + $ref: "#/components/schemas/IdFilter" + - in: query + name: passwordProtection + schema: + $ref: "#/components/schemas/ExistenceFilter" + - in: query + name: passwordProtection.password + schema: + $ref: "#/components/schemas/TextFilter" + - in: query + name: passwordProtection.passwordIsPin + schema: + $ref: "#/components/schemas/PartialBooleanFilter" responses: 200: description: Success @@ -2944,27 +3477,19 @@ paths: content: application/json: schema: - oneOf: - - $ref: "#/components/schemas/RelationshipTemplateReference" - - $ref: "#/components/schemas/RelationshipTemplateReferenceTruncated" - - $ref: "#/components/schemas/TokenReferenceTruncated" - examples: - RelationshipTemplateReference: - value: | - { - "id": "RLT_________________", - "secretKey": "YOUR_SECRET_KEY" - } - RelationshipTemplateReferenceTruncated: - value: | - { - "reference": "YOUR_REFERENCE" - } - TokenReferenceTruncated: - value: | - { - "reference": "YOUR_REFERENCE" - } + type: object + properties: + reference: + type: string + format: byte + nullable: false + description: The base64 encoded truncated reference of the RelationshipTemplate, alternatively of a Token for the RelationshipTemplate. It consists of all information to get and decrypt it. + password: + type: string + nullable: true + description: The password to load the RelationshipTemplate. Only required if the RelationshipTemplate resp. the Token for the RelationshipTemplate is password-protected. + required: + - reference responses: 201: description: Success @@ -3014,6 +3539,22 @@ paths: name: maxNumberOfAllocations schema: $ref: "#/components/schemas/NumberFilter" + - in: query + name: forIdentity + schema: + $ref: "#/components/schemas/IdFilter" + - in: query + name: passwordProtection + schema: + $ref: "#/components/schemas/ExistenceFilter" + - in: query + name: passwordProtection.password + schema: + $ref: "#/components/schemas/TextFilter" + - in: query + name: passwordProtection.passwordIsPin + schema: + $ref: "#/components/schemas/PartialBooleanFilter" responses: 200: description: Success @@ -3044,7 +3585,7 @@ paths: /api/v2/RelationshipTemplates/{id}: get: operationId: getRelationshipTemplate - description: Fetches the `RelationshipTemplate` with the given `id` when the accept header is set to `application/json` or a QrCode containing the reference to the RelationshipTemplate if the accept header it set to `image/png`. + description: Fetches the `RelationshipTemplate` with the given `id` when the accept header is set to `application/json` or a QR Code containing the reference to the RelationshipTemplate if the accept header it set to `image/png`. tags: - RelationshipTemplates parameters: @@ -3089,7 +3630,7 @@ paths: /api/v2/RelationshipTemplates/Own/{id}/Token: post: operationId: createTokenForTemplate - description: Creates a `Token` for the own `RelationshipTemplate` with the given `id` + description: Creates a `Token` for the own `RelationshipTemplate` with the given `id`. If the accept header is set to `image/png` instead of `application/json`, a QR Code containing the reference to the token is shown. tags: - RelationshipTemplates parameters: @@ -3110,8 +3651,16 @@ paths: type: string format: date-time ephemeral: - description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the id and secretKey of the token. Defaults to true. Will be ignored if Accept is set to image/png. + description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the truncatedReference of the token. Defaults to true. Will be ignored if the accept header is set to `image/png`. type: boolean + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this Token. If forIdentity is set for the RelationshipTemplate, that Identity must also be given here. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that will be required to load this Token and information about the password. If passwordProtection is set for the RelationshipTemplate, the same passwordProtection must also be given here. responses: 201: description: Success @@ -3697,13 +4246,21 @@ paths: expiresAt: type: string format: date-time - example: 2023-01-01 + example: 2025-01-01 nullable: false description: A timestamp that describes when this token expires. An expired token cannot be fetched from the platform anymore. However it will still be available for auditing purposes. ephemeral: - description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the id and secretKey of the token. Defaults to false. + description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the truncatedReference of the token. Defaults to false. type: boolean example: false + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this Token. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that will be required to load this Token and information about the password. required: - content - expiresAt @@ -3752,6 +4309,22 @@ paths: name: expiresAt schema: $ref: "#/components/schemas/DateFilter" + - in: query + name: forIdentity + schema: + $ref: "#/components/schemas/IdFilter" + - in: query + name: passwordProtection + schema: + $ref: "#/components/schemas/ExistenceFilter" + - in: query + name: passwordProtection.password + schema: + $ref: "#/components/schemas/TextFilter" + - in: query + name: passwordProtection.passwordIsPin + schema: + $ref: "#/components/schemas/PartialBooleanFilter" responses: 200: description: Success @@ -3789,49 +4362,21 @@ paths: content: application/json: schema: - oneOf: - - type: object - properties: - id: - allOf: - - $ref: "#/components/schemas/TokenID" - nullable: false - description: The ID of the received Token which should be fetched. This is usually shared over the side channel (QR-Code, Link). - secretKey: - type: string - format: byte - nullable: false - description: The base64 encoded secret key which was used to encrypt the Token. This is usually shared over the side channel (QR-Code, Link). - ephemeral: - description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the id and secretKey of the token. Defaults to false. - type: boolean - required: - - id - - secretKey - - type: object - properties: - reference: - type: string - format: byte - nullable: false - description: The base64 encoded truncated reference of the token, which actually consists of the TokenId and the secretKey. - ephemeral: - description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the id and secretKey of the token. Defaults to false. - type: boolean - required: - - reference - examples: - TokenReference: - value: | - { - "id": "TOK_________________", - "secretKey": "YOUR_SECRET_KEY" - } - TokenReferenceTruncated: - value: | - { - "reference": "YOUR_REFERENCE" - } + properties: + reference: + type: string + format: byte + nullable: false + description: The base64 encoded truncated reference of the Token, which actually consists of all information to get and decrypt it. + ephemeral: + description: If set to true the token will will not be cached in the database of the connector. Note that you will not be able to fetch this token unless you remember the truncatedReference of the token. Defaults to false. + type: boolean + password: + type: string + nullable: true + description: The password to load the Token. Only required if the Token is password-protected. + required: + - reference responses: 201: description: Success @@ -3877,6 +4422,22 @@ paths: name: expiresAt schema: $ref: "#/components/schemas/DateFilter" + - in: query + name: forIdentity + schema: + $ref: "#/components/schemas/IdFilter" + - in: query + name: passwordProtection + schema: + $ref: "#/components/schemas/ExistenceFilter" + - in: query + name: passwordProtection.password + schema: + $ref: "#/components/schemas/TextFilter" + - in: query + name: passwordProtection.passwordIsPin + schema: + $ref: "#/components/schemas/PartialBooleanFilter" responses: 200: description: Success @@ -4122,16 +4683,58 @@ components: Example - equal + is true ?foo=true - not equal + is false ?foo=!true

* Due to limitations of Swagger UI, this feature cannot be tested in Swagger UI. Use a REST Client instead (e.g. Postman).

+ PartialBooleanFilter: + type: string + description: > +

Filters for a boolean that's either true or undefined. +

The following operators are supported:

+ + + + + + + + + + + + + +
OperationExample
is true?foo=true
is undefined?foo=!
+

* Due to limitations of Swagger UI, this feature cannot be tested in Swagger UI. Use a REST Client instead (e.g. Postman).

+ + ExistenceFilter: + type: string + description: > +

Filters for whether a property exists, i. e. is not undefined. +

The following operators are supported:

+ + + + + + + + + + + + + +
OperationExample
exists?foo=
doesn't exist?foo=!
+

* Due to limitations of Swagger UI, this feature cannot be tested in Swagger UI. Use a REST Client instead (e.g. Postman).

+ NumberFilter: type: string description: > @@ -4194,8 +4797,7 @@ components: type: string format: Address minLength: 35 - maxLength: 36 - example: id_________________________________ + example: did:e::dids:<22-characters> AttributeID: type: string @@ -4251,14 +4853,6 @@ components: example: REL_________________ description: The ID of a relationship. - RelationshipChangeID: - type: string - format: RelationshipChangeID - minLength: 20 - maxLength: 20 - example: RCH_________________ - description: The ID of a relationship change. - RelationshipTemplateID: type: string format: RelationshipTemplateID @@ -4414,6 +5008,8 @@ components: anyOf: - "$ref": "#/components/schemas/IdentityAttribute" - "$ref": "#/components/schemas/RelationshipAttribute" + isDefault: + type: boolean required: - id - createdAt @@ -4465,6 +5061,74 @@ components: required: - "@type" + AttributeTagCollection: + type: object + additionalProperties: false + properties: + supportedLanguages: + type: array + items: + type: string + example: ["en", "de"] + tagsForAttributeValueTypes: + type: object + additionalProperties: + type: object + additionalProperties: + "$ref": "#/components/schemas/AttributeTag" + example: + PhoneNumber: + emergency: + displayNames: { de: "Notfallkontakt", en: "Emergency Contact" } + children: + first: { displayNames: { de: "Erster Notfallkontakt", en: "First Emergency Contact" } } + second: { displayNames: { de: "Zweiter Notfallkontakt", en: "Second Emergency Contact" } } + private: { displayNames: { de: "Privat", en: "Private" } } + required: + - supportedLanguages + - tagsForAttributeValueTypes + + AttributeTag: + type: object + additionalProperties: false + properties: + children: + additionalProperties: + "$ref": "#/components/schemas/AttributeTag" + type: object + displayNames: + additionalProperties: + type: string + type: object + required: + - displayNames + + CanCreateRepositoryAttributeResponse: + type: object + properties: + isSuccess: + type: boolean + oneOf: + - properties: + isSuccess: + const: true + required: + - isSuccess + additionalProperties: false + + - properties: + isSuccess: + const: false + code: + type: string + message: + type: string + required: + - isSuccess + - code + - message + additionalProperties: false + ConnectorHealth: type: object properties: @@ -4580,7 +5244,7 @@ components: type: string format: date-time description: A timestamp that describes when this file was created. - example: 2020-05-25T11:05:02.924Z + example: 2024-05-25T11:05:02.924Z nullable: false createdBy: allOf: @@ -4594,7 +5258,7 @@ components: type: string format: date-time description: A timestamp that describes when this file will expire. Expired files cannot be accessed anymore. Notice that they will still be available for auditing purposes. - example: 2022-05-25T11:05:02.924Z + example: 2026-05-25T11:05:02.924Z nullable: true mimetype: type: string @@ -4607,7 +5271,7 @@ components: type: string format: byte nullable: false - description: The base64 encoded truncated reference of the file, which actually consists of the FileId and the secretKey. + description: The base64 encoded truncated reference of the File, which actually consists of all information to get and decrypt it. required: - id - title @@ -4621,32 +5285,30 @@ components: - isOwn - truncatedReference - FileReference: + FileReferenceTruncated: type: object properties: - id: - allOf: - - $ref: "#/components/schemas/FileID" - nullable: false - description: The ID of the `File` which should be fetched. This is usually shared within a Token. - secretKey: + reference: type: string - format: byte nullable: false - description: The secret key which was used to encrypt the `File`. This is usually shared within a Token. + description: The base64 encoded truncated reference of the File. required: - - id - - secretKey + - reference - FileReferenceTruncated: + PasswordProtection: type: object properties: - reference: + password: type: string nullable: false - description: The base64 encoded truncated reference of the File. + description: The password that the object is protected with. + passwordIsPin: + type: boolean + enum: [true] + nullable: true + description: true if the password is a PIN, undefined otherwise. If it's a PIN, a numpad is displayed when entering the password in the app. required: - - reference + - password Recipient: type: object @@ -4660,7 +5322,7 @@ components: relationshipId: allOf: - $ref: "#/components/schemas/RelationshipID" - nullable: false + nullable: true description: The id of the relationship to the recipient. required: - address @@ -4679,9 +5341,9 @@ components: mustBeAccepted: type: boolean description: Whether the request must be accepted by the peer. - responseMetadata: + metadata: type: object - description: The metadata will be sent back in the response. + description: Metadata provided by the sender of the request. required: - "@type" - mustBeAccepted @@ -4700,13 +5362,10 @@ components: allOf: - $ref: "#/components/schemas/RequestItem" type: array - mustBeAccepted: - type: boolean - responseMetadata: + metadata: type: object required: - "@type" - - mustBeAccepted - items RequestContent: @@ -4727,7 +5386,6 @@ components: - $ref: "#/components/schemas/RequestItem" - $ref: "#/components/schemas/RequestItemGroup" required: - - "@type" - items RequestResponseContentItem: @@ -4737,13 +5395,9 @@ components: result: type: string enum: [Accepted, Rejected, Failed] - metadata: - type: object - description: The metadata that was sent with the `RequestItem`. required: - "@type" - result - - metadata RequestResponseContentItemGroup: properties: @@ -4753,13 +5407,9 @@ components: type: array items: $ref: "#/components/schemas/RequestResponseContentItem" - metadata: - type: object - description: The metadata that was sent with the `RequestItemGroup`. required: - "@type" - items - - metadata RequestResponseContent: type: object @@ -4780,7 +5430,6 @@ components: - $ref: "#/components/schemas/RequestResponseContentItem" - $ref: "#/components/schemas/RequestResponseContentItemGroup" required: - - "@type" - result - requestId - items @@ -4849,17 +5498,17 @@ components: source: type: object nullable: true - description: The source of this response. Can be a `Message` or a `RelationshipChange`. + description: The source of this response. Can be a `Message` or a `Relationship`. properties: type: type: string - enum: ["Message", "RelationshipChange"] + enum: ["Message", "Relationship"] nullable: false description: The type of the source of this response. reference: type: string nullable: false - description: The id of the `Message` or the `RelationshipChange`. + description: The id of the `Message` or the `Relationship`. required: - type - reference @@ -4943,9 +5592,10 @@ components: items: type: array items: - allOf: + oneOf: - $ref: "#/components/schemas/RequestItemGroup" - $ref: "#/components/schemas/RequestItem" + description: The `items` of a Request can be of type RequestItemGroup or RequestItem. peer: description: The address of the peer that will receive the request. This is optional because in case of a RelationshipTemplate you do not know the peer address yet. For better results you should specify `peer` whenever you know it. allOf: @@ -4966,9 +5616,10 @@ components: items: type: array items: - allOf: + oneOf: - $ref: "#/components/schemas/RequestItemGroup" - $ref: "#/components/schemas/RequestItem" + description: The `items` of a Request can be of type RequestItemGroup or RequestItem. peer: allOf: - $ref: "#/components/schemas/Address" @@ -5006,6 +5657,9 @@ components: content: anyOf: - "$ref": "#/components/schemas/IdentityAttribute" + isDefault: + type: boolean + enum: [true] required: - id - createdAt @@ -5075,8 +5729,12 @@ components: items: $ref: "#/components/schemas/Recipient" content: - allOf: - - $ref: "#/components/schemas/MessageContent" + anyOf: + - $ref: "#/components/schemas/Mail" + - $ref: "#/components/schemas/Request" + - $ref: "#/components/schemas/ResponseWrapper" + - $ref: "#/components/schemas/Notification" + - $ref: "#/components/schemas/ArbitraryMessageContent" nullable: false attachments: type: array @@ -5123,8 +5781,12 @@ components: items: $ref: "#/components/schemas/Recipient" content: - allOf: - - $ref: "#/components/schemas/MessageContent" + anyOf: + - $ref: "#/components/schemas/Mail" + - $ref: "#/components/schemas/Request" + - $ref: "#/components/schemas/ResponseWrapper" + - $ref: "#/components/schemas/Notification" + - $ref: "#/components/schemas/ArbitraryMessageContent" nullable: false attachments: type: array @@ -5143,8 +5805,68 @@ components: - content - isOwn - MessageContent: + ResponseWrapper: + type: object + additionalProperties: false + properties: + "@type": + type: string + enum: ["ResponseWrapper"] + requestId: + $ref: "#/components/schemas/RequestID" + requestSourceReference: + anyOf: + - $ref: "#/components/schemas/RelationshipTemplateID" + - $ref: "#/components/schemas/MessageID" + requestSourceType: + type: string + enum: ["RelationshipTemplate", "Message"] + response: + $ref: "#/components/schemas/RequestResponseContent" + required: + - "@type" + - requestId + - requestSourceReference + - requestSourceType + - response + + Mail: + type: object + additionalProperties: false + properties: + "@type": + type: string + enum: ["Mail"] + to: + type: array + items: + $ref: "#/components/schemas/Address" + cc: + type: array + items: + $ref: "#/components/schemas/Address" + subject: + type: string + body: + type: string + required: + - "@type" + - to + - subject + - body + + ArbitraryMessageContent: type: object + additionalProperties: false + properties: + "@type": + type: string + enum: ["ArbitraryMessageContent"] + value: + type: object + required: + - "@type" + - value Notification: type: object @@ -5152,7 +5874,11 @@ components: required: - id - items + - "@type" properties: + "@type": + type: string + enum: ["Notification"] id: $ref: "#/components/schemas/NotificationID" items: @@ -5160,6 +5886,32 @@ components: items: type: object + CanCreateRelationshipResponse: + type: object + properties: + isSuccess: + type: boolean + oneOf: + - properties: + isSuccess: + const: true + required: + - isSuccess + additionalProperties: false + + - properties: + isSuccess: + const: false + code: + type: string + message: + type: string + required: + - isSuccess + - code + - message + additionalProperties: false + Relationship: type: object properties: @@ -5173,110 +5925,118 @@ components: - $ref: "#/components/schemas/RelationshipTemplate" nullable: false status: - type: string - enum: [Pending, Active, Rejected] - nullable: false - description: The status of the relationship + $ref: "#/components/schemas/RelationshipStatus" peer: allOf: - $ref: "#/components/schemas/Address" nullable: false description: The address of the peer identity. - changes: + peerIdentity: + type: object + additionalProperties: false + properties: + address: + allOf: + - $ref: "#/components/schemas/Address" + nullable: false + publicKey: + type: string + nullable: false + required: + - address + - publicKey + peerDeletionInfo: + type: object + additionalProperties: false + properties: + deletionStatus: + type: string + enum: ["ToBeDeleted", "Deleted"] + description: The status of the deletion + deletionDate: + type: string + description: The date of the deletion + required: + - deletionStatus + - deletionDate + creationContent: + oneOf: + - $ref: "#/components/schemas/RelationshipCreationContent" + - $ref: "#/components/schemas/ArbitraryRelationshipCreationContent" + nullable: false + description: The content at creation. + auditLog: type: array items: - $ref: "#/components/schemas/RelationshipChange" + $ref: "#/components/schemas/RelationshipAuditLogEntry" nullable: false + description: The audit log of the relationship. required: - id - template - status - peer - - changes + - peerIdentity + - creationContent + - auditLog - RelationshipChange: + RelationshipCreationContent: type: object + additionalProperties: false properties: - id: - allOf: - - $ref: "#/components/schemas/RelationshipChangeID" - nullable: false - type: - type: string - nullable: false - enum: [Creation] - status: + "@type": type: string - nullable: false - enum: [Pending, Rejected, Accepted] - request: - $ref: "#/components/schemas/RelationshipChangeRequest" + enum: ["RelationshipCreationContent"] response: - $ref: "#/components/schemas/RelationshipChangeResponse" + $ref: "#/components/schemas/RequestResponseContent" required: - - id - - type - - status - - request + - "@type" + - response - RelationshipChangeRequest: + ArbitraryRelationshipCreationContent: type: object + additionalProperties: false properties: - createdBy: - allOf: - - $ref: "#/components/schemas/Address" - nullable: false - createdByDevice: - allOf: - - $ref: "#/components/schemas/DeviceID" - nullable: false - createdAt: + "@type": type: string - format: date-time - nullable: false - description: A timestamp that describes when this `RelationshipChange` was created on the platform. - readOnly: true - content: - $ref: "#/components/schemas/RelationshipChangeRequestContent" + enum: ["ArbitraryRelationshipCreationContent"] + value: + type: object required: - - createdBy - - createdByDevice - - createdAt - - content - - RelationshipChangeRequestContent: - type: object + - "@type" + - value - RelationshipChangeResponse: + RelationshipTemplateContent: type: object + additionalProperties: false properties: - createdBy: - allOf: - - $ref: "#/components/schemas/Address" - nullable: false - createdByDevice: - allOf: - - $ref: "#/components/schemas/DeviceID" - nullable: false - createdAt: + "@type": type: string - format: date-time - nullable: false - description: A timestamp that describes when this `RelationshipChange` was created on the platform. - readOnly: true - content: - $ref: "#/components/schemas/RelationshipChangeResponseContent" + enum: ["RelationshipTemplateContent"] + title: + type: string + metadata: + type: object + onNewRelationship: + $ref: "#/components/schemas/RequestContent" + onExistingRelationship: + $ref: "#/components/schemas/RequestContent" required: - - createdBy - - createdByDevice - - createdAt - - content - - RelationshipChangeResponseContent: - type: object + - "@type" + - onNewRelationship - RelationshipTemplateContent: + ArbitraryRelationshipTemplateContent: type: object + additionalProperties: false + properties: + "@type": + type: string + enum: ["ArbitraryRelationshipTemplateContent"] + value: + type: object + required: + - "@type" + - value RelationshipTemplate: type: object @@ -5314,19 +6074,24 @@ components: type: string format: date-time description: A timestamp that describes when this relationship template expires. Expired templates cannot be used to create relationship requests anymore. + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this RelationshipTemplate. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that is required to load this RelationshipTemplate and information about the password. content: - allOf: + oneOf: - $ref: "#/components/schemas/RelationshipTemplateContent" + - $ref: "#/components/schemas/ArbitraryRelationshipTemplateContent" nullable: false - secretKey: - type: string - nullable: false - description: The base64 encoded secret key which was used to encrypt the RelationshipTemplate. truncatedReference: type: string format: byte nullable: false - description: The base64 encoded truncated reference of the RelationshipTemplate, which actually consists of the RelationshipTemplateId and the secretKey. + description: The base64 encoded truncated reference of the RelationshipTemplate, which actually consists of all information to get and decrypt it. required: - id - isOwn @@ -5337,32 +6102,53 @@ components: - content - truncatedReference - RelationshipTemplateReference: + RelationshipAuditLogEntry: type: object properties: - id: - allOf: - - $ref: "#/components/schemas/RelationshipTemplateID" - nullable: false - description: The ID of the received RelationshipTemplate which should be fetched. This is usually shared within a Token. - secretKey: + createdAt: type: string - format: byte + format: date-time nullable: false - description: The secret key which was used to encrypt the RelationshipTemplate. This is usually shared within a Token. + createdBy: + $ref: "#/components/schemas/Address" + createdByDevice: + $ref: "#/components/schemas/DeviceID" + reason: + $ref: "#/components/schemas/RelationshipAuditLogEntryReason" + oldStatus: + $ref: "#/components/schemas/RelationshipStatus" + newStatus: + $ref: "#/components/schemas/RelationshipStatus" required: - - id - - secretKey + - createdAt + - createdBy + - reason + - newStatus - RelationshipTemplateReferenceTruncated: - type: object - properties: - reference: - type: string - nullable: false - description: The base64 encoded truncated reference of the RelationshipTemplate. - required: - - reference + RelationshipAuditLogEntryReason: + type: string + enum: + - Creation + - AcceptanceOfCreation + - RejectionOfCreation + - RevocationOfCreation + - Termination + - ReactivationRequested + - AcceptanceOfReactivation + - RejectionOfReactivation + - RevocationOfReactivation + - Decomposition + - DecompositionDueToIdentityDeletion + + RelationshipStatus: + type: string + enum: + - Pending + - Active + - Rejected + - Revoked + - Terminated + - DeletionProposed SignedChallenge: type: object @@ -5440,16 +6226,19 @@ components: format: date-time nullable: false description: A timestamp that describes when this token expires. An expired token cannot be fetched from the platform anymore. However it will still be available for auditing purposes. - secretKey: - type: string - format: byte - nullable: false - description: The base64 encoded secret key which was used to encrypt the Token. This is usually shared over the side channel (QR-Code, Link). + forIdentity: + $ref: "#/components/schemas/Address" + nullable: true + description: The only Identity that may load this Token. + passwordProtection: + $ref: "#/components/schemas/PasswordProtection" + nullable: true + description: The password that is required to load this Token and information about the password. truncatedReference: type: string format: byte nullable: false - description: The base64 encoded truncated reference of the token, which actually consists of the TokenId and the secretKey. + description: The base64 encoded truncated reference of the Token, which actually consists of all information to get and decrypt it. required: - id - createdBy @@ -5457,40 +6246,50 @@ components: - content - createdAt - expiresAt - - secretKey - truncatedReference - TokenReference: + TokenContent: + type: object + description: The arbitrary JSON object which should be shared between creator of the Token and the recipient. + + IdentityMetadata: type: object + additionalProperties: false properties: - id: + reference: allOf: - - $ref: "#/components/schemas/TokenID" + - $ref: "#/components/schemas/Address" nullable: false - description: The ID of the received Token which should be fetched. This is usually shared over the side channel (QR-Code, Link). - secretKey: + description: The address of the identity for that the metadata is stored for. + key: type: string - format: byte - nullable: false - description: The base64 encoded secret key which was used to encrypt the Token. This is usually shared over the side channel (QR-Code, Link). + nullable: true + description: An optional key to identify the metadata. Can be used to store multiple metadata entries for the same identity. There can be at most one IdentityMetadata per `reference` and `key` combination. + value: + type: object + example: { "key": "value" } + description: The metadata value as a JSON object. required: - - id - - secretKey + - reference + - value - TokenReferenceTruncated: + UpsertIdentityMetadataRequest: type: object properties: reference: + allOf: + - $ref: "#/components/schemas/Address" + nullable: false + key: type: string - format: byte + nullable: true + value: + type: object nullable: false - description: The base64 encoded truncated reference of the token, which actually consists of the TokenId and the secretKey. + example: { "key": "value" } required: - reference - - TokenContent: - type: object - description: The arbitrary JSON object which should be shared between creator of the Token and the recipient. + - value # ------------------- General ------------------- @@ -5596,4 +6395,4 @@ components: $ref: "#/components/schemas/HeaderContent_X-Response-Duration-ms" X-Response-Time: schema: - $ref: "#/components/schemas/HeaderContent_X-Response-Time" \ No newline at end of file + $ref: "#/components/schemas/HeaderContent_X-Response-Time" diff --git a/lib/enmeshed/attribute.rb b/lib/enmeshed/attribute.rb index a3c882f30..52baa906c 100644 --- a/lib/enmeshed/attribute.rb +++ b/lib/enmeshed/attribute.rb @@ -36,6 +36,17 @@ def to_h default_attributes.deep_merge(additional_attributes) end + def to_json(*) + { + content: { + value: { + '@type': @type, + value: @value, + }, + }, + }.to_json(*) + end + def id @id ||= persistent_id end diff --git a/lib/enmeshed/connector.rb b/lib/enmeshed/connector.rb index 4c0294a51..4ca806c76 100644 --- a/lib/enmeshed/connector.rb +++ b/lib/enmeshed/connector.rb @@ -20,7 +20,7 @@ def enmeshed_address # @return [String] The ID of the created attribute. def create_attribute(attribute) response = connection.post('/api/v2/Attributes') do |request| - request.body = {content: attribute.to_h}.to_json + request.body = attribute.to_json end parse_result(response, Attribute).id end @@ -67,14 +67,21 @@ def pending_relationships parse_result(response, Relationship) end - # @return [Boolean] Whether the relationship change was changed (accepted or rejected) successfully. - def respond_to_rel_change(relationship_id, change_id, action = 'Accept') - response = - connection.put("/api/v2/Relationships/#{relationship_id}/Changes/#{change_id}/#{action}") do |request| - request.body = {content: {}}.to_json - end + # @return [Boolean] Whether the relationship was accepted successfully. + def accept_relationship(relationship_id) + response = connection.put("/api/v2/Relationships/#{relationship_id}/Accept") Rails.logger.debug do - "Enmeshed::ConnectorApi responded to RelationshipChange with: #{action}; " \ + "Enmeshed::ConnectorApi accepted the relationship; connector response status is #{response.status}" + end + + response.status == 200 + end + + # @return [Boolean] Whether the relationship was rejected successfully. + def reject_relationship(relationship_id) + response = connection.put("/api/v2/Relationships/#{relationship_id}/Reject") + Rails.logger.debug do + 'Enmeshed::ConnectorApi rejected the relationship; ' \ "connector response status is #{response.status}" end diff --git a/lib/enmeshed/object.rb b/lib/enmeshed/object.rb index cb0c042df..45ac79027 100644 --- a/lib/enmeshed/object.rb +++ b/lib/enmeshed/object.rb @@ -18,7 +18,11 @@ def schema end def validate!(instance) - raise ConnectorError.new("Invalid #{klass} schema") unless schema.valid?(instance) + unless schema.valid?(instance) + error = schema.validate(instance).first.fetch('error') + Rails.logger.debug { "Invalid #{klass} schema: #{error}" } + raise ConnectorError.new("Invalid #{klass} schema: #{error}") + end end end end diff --git a/lib/enmeshed/relationship.rb b/lib/enmeshed/relationship.rb index 0b7328ce1..f43483725 100644 --- a/lib/enmeshed/relationship.rb +++ b/lib/enmeshed/relationship.rb @@ -5,12 +5,12 @@ class Relationship < Object STATUS_GROUP_SYNONYMS = YAML.safe_load_file(Rails.root.join('lib/enmeshed/status_group_synonyms.yml')) delegate :expires_at, :nbp_uid, :truncated_reference, to: :@template - attr_reader :relationship_changes + attr_reader :response_items - def initialize(json:, template:, changes: []) + def initialize(json:, template:, response_items: []) @json = json @template = template - @relationship_changes = changes + @response_items = response_items end def peer @@ -37,15 +37,11 @@ def id end def accept! - if relationship_changes.size != 1 - raise ConnectorError.new('Relationship should have exactly one RelationshipChange') - end - Rails.logger.debug do "Enmeshed::ConnectorApi accepting Relationship for template #{truncated_reference}" end - Connector.respond_to_rel_change(id, relationship_changes.first[:id], 'Accept') + Connector.accept_relationship(id) end def reject! @@ -53,9 +49,7 @@ def reject! "Enmeshed::ConnectorApi rejecting Relationship for template #{truncated_reference}" end - @json[:changes].each do |change| - Connector.respond_to_rel_change(id, change[:id], 'Reject') - end + Connector.reject_relationship(id) end class << self @@ -64,7 +58,7 @@ def parse(content) attributes = { json: content, template: RelationshipTemplate.parse(content[:template]), - changes: content[:changes], + response_items: content[:creationContent][:response][:items], } new(**attributes) end @@ -80,17 +74,10 @@ def pending_for(nbp_uid) private def parse_userdata # rubocop:disable Metrics/AbcSize - # Since the RelationshipTemplate has a `maxNumberOfAllocations` attribute set to 1, - # you cannot request multiple Relationships with the same template. - # Further, RelationshipChanges should not be possible before accepting the Relationship. - if relationship_changes.size != 1 - raise ConnectorError.new('Relationship should have exactly one RelationshipChange') + user_provided_attributes = response_items.select do |item| + item[:@type] == 'ReadAttributeAcceptResponseItem' end - change_response_items = relationship_changes.first.dig(:request, :content, :response, :items) - - user_provided_attributes = change_response_items.select {|item| item[:@type] == 'ReadAttributeAcceptResponseItem' } - enmeshed_user_attributes = {} user_provided_attributes.each do |item| @@ -107,7 +94,7 @@ def parse_userdata # rubocop:disable Metrics/AbcSize status_group: parse_status_group(enmeshed_user_attributes['AffiliationRole'].downcase), } rescue NoMethodError - raise ConnectorError.new("Could not parse userdata in relationship change: #{relationship_changes.first}") + raise ConnectorError.new("Could not parse userdata in the response items: #{response_items}") end def parse_status_group(affiliation_role) diff --git a/spec/fixtures/files/enmeshed/display_name_created.json b/spec/fixtures/files/enmeshed/display_name_created.json index 58a96644d..4520a3721 100644 --- a/spec/fixtures/files/enmeshed/display_name_created.json +++ b/spec/fixtures/files/enmeshed/display_name_created.json @@ -3,7 +3,7 @@ "id": "ATT_id_of_a_new_name", "content": { "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "CodeHarbor" diff --git a/spec/fixtures/files/enmeshed/existing_display_name.json b/spec/fixtures/files/enmeshed/existing_display_name.json index 41efb4c33..85559663f 100644 --- a/spec/fixtures/files/enmeshed/existing_display_name.json +++ b/spec/fixtures/files/enmeshed/existing_display_name.json @@ -4,7 +4,7 @@ "id": "ATT_id_of_other_name", "content": { "@type": "IdentityAttribute", - "owner": "id1GyHw8CefvC62VGQ5oTZjT3PvbveyDsrW2", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "Some other display name" @@ -16,7 +16,7 @@ "id": "ATT_id_of_exist_name", "content": { "@type": "IdentityAttribute", - "owner": "id1GyHw8CefvC62VGQ5oTZjT3PvbveyDsrW2", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "CodeHarbor" diff --git a/spec/fixtures/files/enmeshed/get_enmeshed_address.json b/spec/fixtures/files/enmeshed/get_enmeshed_address.json index 3392aab44..a815f9e66 100644 --- a/spec/fixtures/files/enmeshed/get_enmeshed_address.json +++ b/spec/fixtures/files/enmeshed/get_enmeshed_address.json @@ -1,6 +1,6 @@ { "result": { - "address": "id_of_an_example_enmeshed_address_AB", + "address": "did:e:example.com:dids:checksum______________", "publicKey": "eyJwdWIiOiJZV2xVNDV1aEJsZmREa05iQ3RMSnhZaW1zckRGTlBXVHFfcjdLZUlYemN3IiwiYWxnIjozfQ" } } diff --git a/spec/fixtures/files/enmeshed/invalid_role_relationship.json b/spec/fixtures/files/enmeshed/invalid_role_relationship.json deleted file mode 100644 index d85fe0963..000000000 --- a/spec/fixtures/files/enmeshed/invalid_role_relationship.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "result": [ - { - "id": "RELoi9IL4adMbj92K8dn", - "template": { - "id": "RLT_example_id_ABCXY", - "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", - "createdAt": "2024-04-29T13:59:42.688Z", - "content": { - "metadata": {"nbp_uid": "example_uid"}, - "@type": "RelationshipTemplateContent", - "onNewRelationship": { - "items": [ - { - "@type": "ShareAttributeRequestItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "value": { - "@type": "DisplayName", - "value": "CodeHarbor" - } - }, - "mustBeAccepted": true, - "sourceAttributeId": "ATTZP6HUpd2lXMGsHubF" - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "GivenName" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "Surname" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "EMailAddress" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "AffiliationRole" - } - } - ] - } - }, - "expiresAt": "2077-04-29T14:01:42.580Z", - "maxNumberOfAllocations": 1, - "secretKey": "eyJrZXkiOiJBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNRIiwiYWxnIjozfQ", - "truncatedReference": "UkxUNzJ5UXN0NFA1U1VqM0lyOUN8M3xBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNR" - }, - "status": "Pending", - "peer": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "peerIdentity": { - "address": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "publicKey": "eyJwdWIiOiJQak9PTm1VRnJ1UFp1WVFQZWZNcnh1WU1qMzZpc1ptazhaWjU0WE1VbGNnIiwiYWxnIjozfQ", - "realm": "id1" - }, - "changes": [ - { - "id": "RCHNFJ9JD2LayPxn79nO", - "request": { - "createdBy": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "createdByDevice": "DVCwmoDGUfJfiUbQDIPw", - "createdAt": "2024-04-29T14:00:07.422Z", - "content": { - "@type": "RelationshipCreationChangeRequestContent", - "response": { - "items": [ - { - "@type": "ShareAttributeAcceptResponseItem", - "attributeId": "ATTQYtyzn5FyFJVmNPRg", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "GivenName", - "value": "john" - } - }, - "attributeId": "ATTUU918qK6EvDoMdRKD", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "Surname", - "value": "oliver" - } - }, - "attributeId": "ATTcFPnu5rNypgD3mO2y", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "EMailAddress", - "value": "john.oliver@example103.org" - } - }, - "attributeId": "ATTqt42iyqAgHlSuv5yc", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "AffiliationRole", - "value": "some invalid role" - } - }, - "attributeId": "ATTiPFc0dzMTzh5BAdT5", - "result": "Accepted" - } - ], - "requestId": "REQP9vbygJYzNSwE4qnu", - "result": "Accepted" - } - } - }, - "status": "Pending", - "type": "Creation" - } - ] - } - ] -} diff --git a/spec/fixtures/files/enmeshed/missing_attribute_relationship.json b/spec/fixtures/files/enmeshed/missing_attribute_relationship.json deleted file mode 100644 index 14800482c..000000000 --- a/spec/fixtures/files/enmeshed/missing_attribute_relationship.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "result": [ - { - "id": "RELoi9IL4adMbj92K8dn", - "template": { - "id": "RLT_example_id_ABCXY", - "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", - "createdAt": "2024-04-29T13:59:42.688Z", - "content": { - "metadata": {"nbp_uid": "example_uid"}, - "@type": "RelationshipTemplateContent", - "onNewRelationship": { - "items": [ - { - "@type": "ShareAttributeRequestItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "value": { - "@type": "DisplayName", - "value": "CodeHarbor" - } - }, - "mustBeAccepted": true, - "sourceAttributeId": "ATTZP6HUpd2lXMGsHubF" - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "GivenName" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "Surname" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "EMailAddress" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "AffiliationRole" - } - } - ] - } - }, - "expiresAt": "2077-04-29T14:01:42.580Z", - "maxNumberOfAllocations": 1, - "secretKey": "eyJrZXkiOiJBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNRIiwiYWxnIjozfQ", - "truncatedReference": "UkxUNzJ5UXN0NFA1U1VqM0lyOUN8M3xBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNR" - }, - "status": "Pending", - "peer": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "peerIdentity": { - "address": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "publicKey": "eyJwdWIiOiJQak9PTm1VRnJ1UFp1WVFQZWZNcnh1WU1qMzZpc1ptazhaWjU0WE1VbGNnIiwiYWxnIjozfQ", - "realm": "id1" - }, - "changes": [ - { - "id": "RCHNFJ9JD2LayPxn79nO", - "request": { - "createdBy": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "createdByDevice": "DVCwmoDGUfJfiUbQDIPw", - "createdAt": "2024-04-29T14:00:07.422Z", - "content": { - "@type": "RelationshipCreationChangeRequestContent", - "response": { - "items": [ - { - "@type": "ShareAttributeAcceptResponseItem", - "attributeId": "ATTQYtyzn5FyFJVmNPRg", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "GivenName", - "value": "john" - } - }, - "attributeId": "ATTUU918qK6EvDoMdRKD", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "Surname", - "value": "oliver" - } - }, - "attributeId": "ATTcFPnu5rNypgD3mO2y", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "EMailAddress", - "value": "john.oliver@example103.org" - } - }, - "attributeId": "ATTqt42iyqAgHlSuv5yc", - "result": "Accepted" - } - ], - "requestId": "REQP9vbygJYzNSwE4qnu", - "result": "Accepted" - } - } - }, - "status": "Pending", - "type": "Creation" - } - ] - } - ] -} diff --git a/spec/fixtures/files/enmeshed/relationship_expired.json b/spec/fixtures/files/enmeshed/relationship_expired.json index 517c25e18..aab8b4503 100644 --- a/spec/fixtures/files/enmeshed/relationship_expired.json +++ b/spec/fixtures/files/enmeshed/relationship_expired.json @@ -5,7 +5,7 @@ "template": { "id": "RLT_example_id_ABCXY", "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "createdBy": "did:e:example.com:dids:checksum______________", "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", "createdAt": "2024-04-29T13:59:42.688Z", "content": { @@ -17,7 +17,7 @@ "@type": "ShareAttributeRequestItem", "attribute": { "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "CodeHarbor" @@ -67,88 +67,66 @@ "truncatedReference": "UkxUNzJ5UXN0NFA1U1VqM0lyOUN8M3xBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNR" }, "status": "Pending", - "peer": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", + "creationContent": { + "@type": "RelationshipCreationContent", + "response": { + "result": "Accepted", + "requestId": "REQ_________________", + "items": [ + { + "@type": "ShareAttributeAcceptResponseItem", + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { "@type": "GivenName", "value": "John" } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { "@type": "Surname", "value": "Oliver" } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { + "@type": "EMailAddress", + "value": "john.oliver@example103.org" + } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + } + ] + } + }, + "peer": "did:e:example.com:dids:checksum______________", "peerIdentity": { - "address": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "publicKey": "eyJwdWIiOiJQak9PTm1VRnJ1UFp1WVFQZWZNcnh1WU1qMzZpc1ptazhaWjU0WE1VbGNnIiwiYWxnIjozfQ", - "realm": "id1" + "address": "did:e:example.com:dids:checksum______________", + "publicKey": "eyJwdWIiOiJwdG5GRVZKd245VEtzR2k4V05CWlU0b1FFNk0zR3BFeF9QeVJKcHBWVW5vIiwiYWxnIjozfQ" }, - "changes": [ + "auditLog": [ { - "id": "RCHNFJ9JD2LayPxn79nO", - "request": { - "createdBy": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "createdByDevice": "DVCwmoDGUfJfiUbQDIPw", - "createdAt": "2024-04-29T14:00:07.422Z", - "content": { - "@type": "RelationshipCreationChangeRequestContent", - "response": { - "items": [ - { - "@type": "ShareAttributeAcceptResponseItem", - "attributeId": "ATTQYtyzn5FyFJVmNPRg", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "GivenName", - "value": "john" - } - }, - "attributeId": "ATTUU918qK6EvDoMdRKD", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "Surname", - "value": "oliver" - } - }, - "attributeId": "ATTcFPnu5rNypgD3mO2y", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "EMailAddress", - "value": "john.oliver@example103.org" - } - }, - "attributeId": "ATTqt42iyqAgHlSuv5yc", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "AffiliationRole", - "value": "Schüler:in" - } - }, - "attributeId": "ATTiPFc0dzMTzh5BAdT5", - "result": "Accepted" - } - ], - "requestId": "REQP9vbygJYzNSwE4qnu", - "result": "Accepted" - } - } - }, - "status": "Pending", - "type": "Creation" + "createdAt": "2024-11-05T11:13:12.266Z", + "createdBy": "did:e:example.com:dids:checksum______________", + "createdByDevice": "DVC_________________", + "reason": "Creation", + "oldStatus": "Pending", + "newStatus": "Pending" } ] } diff --git a/spec/fixtures/files/enmeshed/relationship_impossible_attributes.json b/spec/fixtures/files/enmeshed/relationship_impossible_attributes.json deleted file mode 100644 index 8528b6119..000000000 --- a/spec/fixtures/files/enmeshed/relationship_impossible_attributes.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "result": [ - { - "id": "RELoi9IL4adMbj92K8dn", - "template": { - "id": "RLT_example_id_ABCXY", - "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", - "createdAt": "2024-04-29T13:59:42.688Z", - "content": { - "metadata": {"nbp_uid": "example_uid"}, - "@type": "RelationshipTemplateContent", - "onNewRelationship": { - "items": [ - { - "@type": "ShareAttributeRequestItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", - "value": { - "@type": "DisplayName", - "value": "CodeHarbor" - } - }, - "mustBeAccepted": true, - "sourceAttributeId": "ATTZP6HUpd2lXMGsHubF" - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "GivenName" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "Surname" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "EMailAddress" - } - }, - { - "@type": "ReadAttributeRequestItem", - "mustBeAccepted": true, - "query": { - "@type": "IdentityAttributeQuery", - "valueType": "AffiliationRole" - } - } - ] - } - }, - "expiresAt": "2077-04-29T14:01:42.580Z", - "maxNumberOfAllocations": 1, - "secretKey": "eyJrZXkiOiJBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNRIiwiYWxnIjozfQ", - "truncatedReference": "UkxUNzJ5UXN0NFA1U1VqM0lyOUN8M3xBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNR" - }, - "status": "Pending", - "peer": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "peerIdentity": { - "address": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "publicKey": "eyJwdWIiOiJQak9PTm1VRnJ1UFp1WVFQZWZNcnh1WU1qMzZpc1ptazhaWjU0WE1VbGNnIiwiYWxnIjozfQ", - "realm": "id1" - }, - "changes": [ - { - "id": "RCHNFJ9JD2LayPxn79nO", - "request": { - "createdBy": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "createdByDevice": "DVCwmoDGUfJfiUbQDIPw", - "createdAt": "2024-04-29T14:00:07.422Z", - "content": { - "@type": "RelationshipCreationChangeRequestContent", - "response": { - "items": [ - { - "@type": "ShareAttributeAcceptResponseItem", - "attributeId": "ATTQYtyzn5FyFJVmNPRg", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "GivenName", - "value": "john" - } - }, - "attributeId": "ATTUU918qK6EvDoMdRKD", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "Surname", - "value": "oliver" - } - }, - "attributeId": "ATTcFPnu5rNypgD3mO2y", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "EMailAddress", - "value": "already_taken@problem.eu" - } - }, - "attributeId": "ATTqt42iyqAgHlSuv5yc", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "AffiliationRole", - "value": "Schüler:in" - } - }, - "attributeId": "ATTiPFc0dzMTzh5BAdT5", - "result": "Accepted" - } - ], - "requestId": "REQP9vbygJYzNSwE4qnu", - "result": "Accepted" - } - } - }, - "status": "Pending", - "type": "Creation" - } - ] - } - ] -} diff --git a/spec/fixtures/files/enmeshed/relationship_template.json b/spec/fixtures/files/enmeshed/relationship_template.json index 24871ea89..073598939 100644 --- a/spec/fixtures/files/enmeshed/relationship_template.json +++ b/spec/fixtures/files/enmeshed/relationship_template.json @@ -3,7 +3,7 @@ { "id": "RLT_example_id_ABCXY", "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "createdBy": "did:e:example.com:dids:checksum______________", "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", "createdAt": "2024-04-29T12:47:04.436Z", "content": { @@ -15,7 +15,7 @@ "@type": "ShareAttributeRequestItem", "attribute": { "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "CodeHarbor" diff --git a/spec/fixtures/files/enmeshed/valid_relationship_created.json b/spec/fixtures/files/enmeshed/valid_relationship_created.json index c6d993a70..89c7250f1 100644 --- a/spec/fixtures/files/enmeshed/valid_relationship_created.json +++ b/spec/fixtures/files/enmeshed/valid_relationship_created.json @@ -5,19 +5,20 @@ "template": { "id": "RLT_example_id_ABCXY", "isOwn": true, - "createdBy": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "createdBy": "did:e:example.com:dids:checksum______________", "createdByDevice": "DVCiZvbvF6dg7cr1nwLw", "createdAt": "2024-04-29T13:59:42.688Z", "content": { - "metadata": {"nbp_uid": "example_uid"}, "@type": "RelationshipTemplateContent", + "metadata": { "nbp_uid": "example_uid" }, "onNewRelationship": { + "expiresAt": "2077-11-07T23:36:43.893Z", "items": [ { "@type": "ShareAttributeRequestItem", "attribute": { "@type": "IdentityAttribute", - "owner": "id1JttmYYvjT6wojkJ9rpaFkMUKHAWKr9RDf", + "owner": "did:e:example.com:dids:checksum______________", "value": { "@type": "DisplayName", "value": "CodeHarbor" @@ -63,92 +64,82 @@ }, "expiresAt": "2077-04-29T14:01:42.580Z", "maxNumberOfAllocations": 1, - "secretKey": "eyJrZXkiOiJBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNRIiwiYWxnIjozfQ", "truncatedReference": "UkxUNzJ5UXN0NFA1U1VqM0lyOUN8M3xBRWxfNURfaWNZeFRZank1UDVXUmJSTUliMkNaYWxuOUZrcDRPSGF4XzNR" }, "status": "Pending", - "peer": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", + "creationContent": { + "@type": "RelationshipCreationContent", + "response": { + "result": "Accepted", + "requestId": "REQ_________________", + "items": [ + { + "@type": "ShareAttributeAcceptResponseItem", + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { "@type": "GivenName", "value": "John" } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { "@type": "Surname", "value": "Oliver" } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { + "@type": "EMailAddress", + "value": "john.oliver@example103.org" + } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + }, + { + "@type": "ReadAttributeAcceptResponseItem", + "attribute": { + "@type": "IdentityAttribute", + "owner": "did:e:example.com:dids:checksum______________", + "value": { + "@type": "AffiliationRole", + "value": "Lehrer:in" + } + }, + "attributeId": "ATT_________________", + "result": "Accepted" + } + ] + } + }, + "peer": "did:e:example.com:dids:checksum______________", "peerIdentity": { - "address": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "publicKey": "eyJwdWIiOiJQak9PTm1VRnJ1UFp1WVFQZWZNcnh1WU1qMzZpc1ptazhaWjU0WE1VbGNnIiwiYWxnIjozfQ", - "realm": "id1" + "address": "did:e:example.com:dids:checksum______________", + "publicKey": "eyJwdWIiOiJwdG5GRVZKd245VEtzR2k4V05CWlU0b1FFNk0zR3BFeF9QeVJKcHBWVW5vIiwiYWxnIjozfQ" }, - "changes": [ + "auditLog": [ { - "id": "RCHNFJ9JD2LayPxn79nO", - "request": { - "createdBy": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "createdByDevice": "DVCwmoDGUfJfiUbQDIPw", - "createdAt": "2024-04-29T14:00:07.422Z", - "content": { - "@type": "RelationshipCreationChangeRequestContent", - "response": { - "items": [ - { - "@type": "ShareAttributeAcceptResponseItem", - "attributeId": "ATTQYtyzn5FyFJVmNPRg", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "GivenName", - "value": "john" - } - }, - "attributeId": "ATTUU918qK6EvDoMdRKD", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "Surname", - "value": "oliver" - } - }, - "attributeId": "ATTcFPnu5rNypgD3mO2y", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "EMailAddress", - "value": "john.oliver@example103.org" - } - }, - "attributeId": "ATTqt42iyqAgHlSuv5yc", - "result": "Accepted" - }, - { - "@type": "ReadAttributeAcceptResponseItem", - "attribute": { - "@type": "IdentityAttribute", - "owner": "id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp", - "value": { - "@type": "AffiliationRole", - "value": "Lehrer:in" - } - }, - "attributeId": "ATTiPFc0dzMTzh5BAdT5", - "result": "Accepted" - } - ], - "requestId": "REQP9vbygJYzNSwE4qnu", - "result": "Accepted" - } - } - }, - "status": "Pending", - "type": "Creation" + "createdAt": "2024-11-05T11:13:12.266Z", + "createdBy": "did:e:example.com:dids:checksum______________", + "createdByDevice": "DVC_________________", + "reason": "Creation", + "oldStatus": "Pending", + "newStatus": "Active" } ] } diff --git a/spec/lib/enmeshed/attribute_spec.rb b/spec/lib/enmeshed/attribute_spec.rb index f04094f00..70a155ad1 100644 --- a/spec/lib/enmeshed/attribute_spec.rb +++ b/spec/lib/enmeshed/attribute_spec.rb @@ -10,4 +10,20 @@ expect { attribute.to_h }.to raise_error(NotImplementedError) end end + + describe '.parse' do + subject(:parsed_attribute) { described_class.parse(content) } + + let(:content) { {content: {'@type': 'Attribute', owner: ''}} } + + context 'with non-API-compliant content' do + it 'raises an error and logs it' do + expect(Rails.logger).to receive(:debug) do |&block| + expect(block).to be_a(Proc) + expect(block.call).to match(/Invalid Attribute schema:/) + end + expect { parsed_attribute }.to raise_error(Enmeshed::ConnectorError, /Invalid Attribute schema:/) + end + end + end end diff --git a/spec/lib/enmeshed/connector_spec.rb b/spec/lib/enmeshed/connector_spec.rb index e59ea3d44..416c0529f 100644 --- a/spec/lib/enmeshed/connector_spec.rb +++ b/spec/lib/enmeshed/connector_spec.rb @@ -90,7 +90,7 @@ end it 'returns the parsed address' do - expect(enmeshed_address).to eq 'id_of_an_example_enmeshed_address_AB' + expect(enmeshed_address).to eq 'did:e:example.com:dids:checksum______________' end end @@ -122,13 +122,11 @@ end end - describe '.respond_to_rel_change' do - subject(:respond_to_rel_change) { connector.respond_to_rel_change(relationship_id, change_id) } - - let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/#{relationship_id}/Changes/#{change_id}/Accept") } + describe '.accept_relationship' do + subject(:accept_relationship) { connector.accept_relationship(relationship_id) } let(:relationship_id) { 'RELoi9IL4adMbj92K8dn' } - let(:change_id) { 'RCHNFJ9JD2LayPxn79nO' } + let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/#{relationship_id}/Accept") } context 'with a successful response' do before do @@ -136,17 +134,45 @@ end it 'is true' do - expect(respond_to_rel_change).to be_truthy + expect(accept_relationship).to be_truthy + end + end + + context 'with a failed response' do + before do + accept_request_stub.to_return(status: 404) + end + + it 'is false' do + expect(accept_relationship).to be false + end + end + end + + describe '.reject_relationship' do + subject(:reject_relationship) { connector.reject_relationship(relationship_id) } + + let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/#{relationship_id}/Reject") } + + let(:relationship_id) { 'RELoi9IL4adMbj92K8dn' } + + context 'with a successful response' do + before do + reject_request_stub + end + + it 'is true' do + expect(reject_relationship).to be_truthy end end context 'with a failed response' do before do - accept_request_stub.to_return(status: 500) + reject_request_stub.to_return(status: 404) end it 'is false' do - expect(respond_to_rel_change).to be false + expect(reject_relationship).to be false end end end diff --git a/spec/lib/enmeshed/relationship_spec.rb b/spec/lib/enmeshed/relationship_spec.rb index 57327c4a0..9401d87a7 100644 --- a/spec/lib/enmeshed/relationship_spec.rb +++ b/spec/lib/enmeshed/relationship_spec.rb @@ -3,12 +3,12 @@ require 'rails_helper' RSpec.describe Enmeshed::Relationship do - let(:connector_api_url) { "#{Settings.dig(:omniauth, :nbp, :enmeshed, :connector_url)}/api/v2"} + let(:connector_api_url) { "#{Settings.dig(:omniauth, :nbp, :enmeshed, :connector_url)}/api/v2" } let(:json) do - JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, - symbolize_names: true)[:result].first + JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, symbolize_names: true)[:result].first end let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } + let(:response_items) { json[:creationContent][:response][:items] } before do allow(User).to receive(:omniauth_providers).and_return([:nbp]) @@ -18,7 +18,7 @@ subject(:pending_for) { described_class.pending_for(nbp_uid) } let(:nbp_uid) { 'example_uid' } - let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } + let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Reject") } before do stub_request(:get, "#{connector_api_url}/Relationships?status=Pending") @@ -53,11 +53,11 @@ end describe '#accept!' do - subject(:accept) { described_class.new(json:, template:, changes: json[:changes]).accept! } + subject(:accept) { described_class.new(json:, template:, response_items: json[:creationContent][:response]).accept! } let(:json) { JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, symbolize_names: true)[:result].first } let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } - let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Accept") } + let(:accept_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Accept") } before do accept_request_stub @@ -67,22 +67,14 @@ expect(accept).to be_truthy expect(accept_request_stub).to have_been_requested end - - context 'without a RelationshipChange' do - subject(:accept) { described_class.new(json:, template:, changes: []).accept! } - - it 'raises an error' do - expect { accept }.to raise_error Enmeshed::ConnectorError - end - end end describe '#reject!' do - subject(:reject) { described_class.new(json:, template:, changes: json[:changes]).reject! } + subject(:reject) { described_class.new(json:, template:, response_items:).reject! } let(:json) { JSON.parse(file_fixture('enmeshed/valid_relationship_created.json').read, symbolize_names: true)[:result].first } let(:template) { Enmeshed::RelationshipTemplate.parse(json[:template]) } - let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Changes/RCHNFJ9JD2LayPxn79nO/Reject") } + let(:reject_request_stub) { stub_request(:put, "#{connector_api_url}/Relationships/RELoi9IL4adMbj92K8dn/Reject") } before do reject_request_stub @@ -95,60 +87,59 @@ end describe '#userdata' do - subject(:userdata) { described_class.new(json:, template:, changes: json[:changes]).userdata } + subject(:userdata) { described_class.new(json:, template:, response_items:).userdata } it 'returns the requested data' do - expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'john', last_name: 'oliver', :status_group => :educator}) + expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', + status_group: :educator}) end - context 'with a blank attribute' do + context 'with a synonym of "learner" as status group' do before do - json[:@type] - json[:changes].first[:request][:content][:response][:items].last[:attribute][:value][:value] = ' ' + response_items.last[:attribute][:value][:value] = 'Schüler' end - # The validations of the User model will take care - it 'passes' do - expect { userdata }.not_to raise_error + it 'returns the requested data' do + expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', + status_group: :learner}) end end - context 'with a missing attribute' do + context 'with a blank attribute' do before do - json[:changes].first[:request][:content][:response][:items].pop + response_items.last[:attribute][:value][:value] = ' ' end - it 'raises an error' do - expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'AffiliationRole must not be empty') + # The validations of the User model will take care + it 'passes' do + expect { userdata }.not_to raise_error end end - context 'with more than one RelationshipChange' do + context 'with a missing attribute' do before do - json[:changes] += json[:changes] + response_items.pop end it 'raises an error' do - expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'Relationship should have exactly one RelationshipChange') + expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'AffiliationRole must not be empty') end end context 'without any provided attributes' do - before do - json[:changes].first[:request][:content][:response][:items] = nil - end + let(:response_items) { nil } it 'raises an error' do - expect { userdata }.to raise_error(Enmeshed::ConnectorError, "Could not parse userdata in relationship change: #{json[:changes].first}") + expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'Could not parse userdata in the response items: ') end end end describe '#peer' do - subject(:peer) { described_class.new(json:, template:, changes: json[:changes]).peer } + subject(:peer) { described_class.new(json:, template:, response_items:).peer } it 'returns the peer id' do - expect(peer).to eq 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp' + expect(peer).to eq 'did:e:example.com:dids:checksum______________' end end end diff --git a/spec/lib/enmeshed/relationship_template_spec.rb b/spec/lib/enmeshed/relationship_template_spec.rb index 7919f74e9..08cc9f70e 100644 --- a/spec/lib/enmeshed/relationship_template_spec.rb +++ b/spec/lib/enmeshed/relationship_template_spec.rb @@ -56,7 +56,8 @@ context 'with an existing display name' do before do - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=" \ + 'did:e:example.com:dids:checksum______________&content.value.@type=DisplayName') .to_return(body: file_fixture('enmeshed/existing_display_name.json')) end @@ -67,7 +68,8 @@ context 'with no existing display name' do before do - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=" \ + 'did:e:example.com:dids:checksum______________&content.value.@type=DisplayName') .to_return(body: file_fixture('enmeshed/no_existing_display_name.json')) stub_request(:post, "#{connector_api_url}/Attributes") .to_return(body: file_fixture('enmeshed/display_name_created.json')) @@ -176,7 +178,7 @@ it 'returns a link to the platforms qr code view action' do expect(qr_code_path).to eq '/users/nbp_wallet/qr_code' \ - '?truncated_reference=RelationshipTemplateExampleTruncatedReferenceA%3D%3D' + '?truncated_reference=RelationshipTemplateExampleTruncatedReferenceA%3D%3D' end end @@ -195,7 +197,8 @@ stub_request(:get, "#{connector_api_url}/Account/IdentityInfo") .to_return(body: file_fixture('enmeshed/get_enmeshed_address.json')) - stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=id_of_an_example_enmeshed_address_AB&content.value.@type=DisplayName") + stub_request(:get, "#{connector_api_url}/Attributes?content.@type=IdentityAttribute&content.owner=" \ + 'did:e:example.com:dids:checksum______________&content.value.@type=DisplayName') .to_return(body: file_fixture('enmeshed/existing_display_name.json')) end From 73b021c488039c5a31159a38d07c9dc75a8c33e8 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Wed, 22 Jan 2025 11:51:08 +0100 Subject: [PATCH 11/18] style(enmeshed): tweak `Connector::fetch_existing_attribute` This decreases the Assignment Branch Condition size of the method. --- lib/enmeshed/connector.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/enmeshed/connector.rb b/lib/enmeshed/connector.rb index 4ca806c76..e498dac35 100644 --- a/lib/enmeshed/connector.rb +++ b/lib/enmeshed/connector.rb @@ -28,9 +28,11 @@ def create_attribute(attribute) # @return [String, nil] The ID of the existing attribute or nil if none was found. def fetch_existing_attribute(attribute) response = connection.get('/api/v2/Attributes') do |request| - request.params['content.@type'] = attribute.klass - request.params['content.owner'] = attribute.owner - request.params['content.value.@type'] = attribute.type + request.params.tap do |p| + p['content.@type'] = attribute.klass + p['content.owner'] = attribute.owner + p['content.value.@type'] = attribute.type + end end parse_result(response, Attribute).find {|attr| attr.value == attribute.value }&.id end From 73ff03a42d1a90f7fc8bb9fd4e9809fc772ca19c Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Thu, 30 Jan 2025 13:38:48 +0100 Subject: [PATCH 12/18] fix(nbp): stop QR code countdown when leaving the page Part of XI-6523 --- app/assets/javascripts/nbp_wallet.js | 43 +++++++++++-------- app/assets/stylesheets/users.css.scss | 2 +- .../users/nbp_wallet_controller.rb | 2 +- app/views/users/nbp_wallet/connect.html.slim | 14 ++++-- 4 files changed, 38 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/nbp_wallet.js b/app/assets/javascripts/nbp_wallet.js index edde38cf0..21830e40e 100644 --- a/app/assets/javascripts/nbp_wallet.js +++ b/app/assets/javascripts/nbp_wallet.js @@ -1,43 +1,52 @@ let templateValidity = 0; -let finalizing = false; +let intervalID; +let timeoutID; const checkStatus = async () => { - if (!finalizing) { - try { - const response = await fetch('/nbp_wallet/relationship_status'); - const json = await response.json(); - - if (json.status === 'ready' && !finalizing) { - finalizing = true; - window.location.pathname = '/nbp_wallet/finalize'; - } - } catch (error) { - console.error(error); + try { + const response = await fetch(Routes.nbp_wallet_relationship_status_users_path()); + const json = await response.json(); + + if (json.status === 'ready' && window.location.pathname === Routes.nbp_wallet_connect_users_path()) { + window.location.pathname = Routes.nbp_wallet_finalize_users_path(); + return; } + } catch (error) { + console.error(error); } - setTimeout(checkStatus, 1000); + timeoutID = setTimeout(checkStatus, 1000); }; const countdownValidity = () => { if (templateValidity > 0) { templateValidity -= 1; } - if (templateValidity === 0) { + if (templateValidity === 0 && window.location.pathname === Routes.nbp_wallet_connect_users_path()) { window.location.reload(); } }; +window.addEventListener("turbolinks:before-render", () => { + clearInterval(intervalID); + clearTimeout(timeoutID); +}); + +window.addEventListener("beforeunload", () => { + clearInterval(intervalID); + clearTimeout(timeoutID); +}); + $(document).on('turbolinks:load', function () { - if (window.location.pathname !== '/nbp_wallet/connect') { + if (window.location.pathname !== Routes.nbp_wallet_connect_users_path()) { return; } - document.querySelector('.regenerate-qr-code-button').addEventListener('click', () => { + document.querySelector('[data-behavior=reload-on-click]').addEventListener('click', () => { window.location.reload(); }); // Subtract 5 seconds to make sure the displayed code is always valid (accounting for loading times) templateValidity = document.querySelector('[data-id="nbp_wallet_qr_code"]').dataset.remainingValidity - 5; checkStatus(); - setInterval(countdownValidity, 1000); + intervalID = setInterval(countdownValidity, 1000); }); diff --git a/app/assets/stylesheets/users.css.scss b/app/assets/stylesheets/users.css.scss index 04f2979a2..7313fb42e 100644 --- a/app/assets/stylesheets/users.css.scss +++ b/app/assets/stylesheets/users.css.scss @@ -10,7 +10,7 @@ display: inline; } -img.nbp_wallet_qr_code { +img.pixelated { image-rendering: pixelated; width: 100%; } diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index bb9e9ad9d..397b2831a 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -11,7 +11,7 @@ def connect redirect_to nbp_wallet_finalize_users_path and return end - @template = Enmeshed::RelationshipTemplate.create!(nbp_uid: @provider_uid) + @relationship_template = Enmeshed::RelationshipTemplate.create!(nbp_uid: @provider_uid) rescue Enmeshed::ConnectorError, Faraday::Error => e Sentry.capture_exception(e) Rails.logger.debug { e } diff --git a/app/views/users/nbp_wallet/connect.html.slim b/app/views/users/nbp_wallet/connect.html.slim index 1d870415d..d5e84ecdf 100644 --- a/app/views/users/nbp_wallet/connect.html.slim +++ b/app/views/users/nbp_wallet/connect.html.slim @@ -5,15 +5,21 @@ .row .col-8.col-md-4.col-lg-3 - = link_to @template.url - = image_tag @template.qr_code_path, data: {'template-validity': @template.remaining_validity&.seconds}, alt: t('.qr_code_alt_text'), class: 'img-fluid nbp_wallet_qr_code' - = button_tag class: 'btn btn-primary regenerate-qr-code-button w-100 mt-3' do + = link_to @relationship_template.url + = image_tag @relationship_template.qr_code_path, + data: {id: 'nbp_wallet_qr_code', 'remaining-validity': @relationship_template.remaining_validity.seconds}, + alt: t('.qr_code_alt_text'), + class: 'img-fluid pixelated' + .btn.btn-primary.w-100.mt-3 data-behavior='reload-on-click' = t('.regenerate_code') = link_to destroy_user_session_path, method: :delete, class: 'btn btn-outline-danger w-100 mt-3' = t('.cancel_registration') .col-8.col-md-6.mt-3.ms-md-3.mt-md-0 p.fs-6 - = t('.info_html', alternative_link: @template.url, app_store_link: @template.app_store_link, play_store_link: @template.play_store_link) + = t('.info_html', + alternative_link: @relationship_template.url, + app_store_link: @relationship_template.app_store_link, + play_store_link: @relationship_template.play_store_link) hr.mt-5 h5 From b2c9ae51a611e1999f7bdbbcd5295f6d12e21912 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Tue, 4 Feb 2025 13:53:41 +0100 Subject: [PATCH 13/18] fix(nbp): abort finalize action if no relationship exists When appending a return statement to a method call, either with `and` or `&&`, the return won't happen if there is a tailing one-line if statement. Part of XI-6523 --- app/controllers/users/nbp_wallet_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index 397b2831a..dbf7bc5fc 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -37,7 +37,7 @@ def relationship_status def finalize relationship = Enmeshed::Relationship.pending_for(@provider_uid) - abort_and_refresh(relationship) and return if relationship.blank? + return abort_and_refresh(relationship) if relationship.blank? accept_and_create_user(relationship) rescue Enmeshed::ConnectorError, Faraday::Error => e From 88edf75d68057dade4b98701826a215231d912c7 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Wed, 5 Feb 2025 12:39:26 +0100 Subject: [PATCH 14/18] refactor(enmeshed): move serialization for creating IdentityAttributes to `Attribute::Identity` The former implementation of `Attibute.to_json` was very specific to IdentityAttributes. It actually only worked for simple IdentityAttributes and would need adaptations for complex IdentityAttributes. Part of XI-6523 --- lib/enmeshed/attribute.rb | 15 ++++----------- lib/enmeshed/attribute/identity.rb | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/enmeshed/attribute.rb b/lib/enmeshed/attribute.rb index 52baa906c..77e53d764 100644 --- a/lib/enmeshed/attribute.rb +++ b/lib/enmeshed/attribute.rb @@ -32,21 +32,14 @@ def self.parse(content) desired_klass.new(**attributes.compact) end + # Serialize all available attributes of an Attribute object. + # E.g. for sharing it when making the API request to create a relationship template. + # + # @return [Hash] def to_h default_attributes.deep_merge(additional_attributes) end - def to_json(*) - { - content: { - value: { - '@type': @type, - value: @value, - }, - }, - }.to_json(*) - end - def id @id ||= persistent_id end diff --git a/lib/enmeshed/attribute/identity.rb b/lib/enmeshed/attribute/identity.rb index bc65da45a..5b1978267 100644 --- a/lib/enmeshed/attribute/identity.rb +++ b/lib/enmeshed/attribute/identity.rb @@ -2,6 +2,21 @@ module Enmeshed class Attribute::Identity < Attribute + # Serialize the Identity object to fit the content requirements when creating + # an IdentityAttribute via the Connector API (available under Connector::CONNECTOR_URL) + # + # @return [String] JSON + def to_json(*) + { + content: { + value: { + '@type': @type, + value: @value, + }, + }, + }.to_json(*) + end + private def additional_attributes From bd5a59a021afd64e816e7846c89cd80a0c494611 Mon Sep 17 00:00:00 2001 From: Nele Noack Date: Tue, 11 Feb 2025 16:35:18 +0100 Subject: [PATCH 15/18] refactor(enmeshed): leave the validation of the userdata to the `User` model Part of XI-6523 --- lib/enmeshed/relationship.rb | 8 +- spec/lib/enmeshed/relationship_spec.rb | 57 +++++++--- .../users/nbp_wallet/finalize_spec.rb | 101 +++++++----------- 3 files changed, 88 insertions(+), 78 deletions(-) diff --git a/lib/enmeshed/relationship.rb b/lib/enmeshed/relationship.rb index f43483725..6af4c3cb5 100644 --- a/lib/enmeshed/relationship.rb +++ b/lib/enmeshed/relationship.rb @@ -74,6 +74,8 @@ def pending_for(nbp_uid) private def parse_userdata # rubocop:disable Metrics/AbcSize + return if response_items.blank? + user_provided_attributes = response_items.select do |item| item[:@type] == 'ReadAttributeAcceptResponseItem' end @@ -91,13 +93,13 @@ def parse_userdata # rubocop:disable Metrics/AbcSize email: enmeshed_user_attributes['EMailAddress'], first_name: enmeshed_user_attributes['GivenName'], last_name: enmeshed_user_attributes['Surname'], - status_group: parse_status_group(enmeshed_user_attributes['AffiliationRole'].downcase), + status_group: parse_status_group(enmeshed_user_attributes['AffiliationRole']&.downcase), } - rescue NoMethodError - raise ConnectorError.new("Could not parse userdata in the response items: #{response_items}") end def parse_status_group(affiliation_role) + return if affiliation_role.blank? + if STATUS_GROUP_SYNONYMS['learner'].any? {|synonym| synonym.downcase.include? affiliation_role } :learner elsif STATUS_GROUP_SYNONYMS['educator'].any? {|synonym| synonym.downcase.include? affiliation_role } diff --git a/spec/lib/enmeshed/relationship_spec.rb b/spec/lib/enmeshed/relationship_spec.rb index 9401d87a7..bbb585043 100644 --- a/spec/lib/enmeshed/relationship_spec.rb +++ b/spec/lib/enmeshed/relationship_spec.rb @@ -89,9 +89,20 @@ describe '#userdata' do subject(:userdata) { described_class.new(json:, template:, response_items:).userdata } - it 'returns the requested data' do - expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', - status_group: :educator}) + shared_examples 'parsed userdata' do + it 'passes' do + expect { userdata }.not_to raise_error + end + + it 'returns the requested data' do + expect(userdata).to eq parsed_userdata + end + end + + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) do + {email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', status_group: :educator} + end end context 'with a synonym of "learner" as status group' do @@ -99,38 +110,54 @@ response_items.last[:attribute][:value][:value] = 'Schüler' end - it 'returns the requested data' do - expect(userdata).to eq({email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', - status_group: :learner}) + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) do + {email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', status_group: :learner} + end end end - context 'with a blank attribute' do + context 'with gibberish as status group' do + before do + response_items.last[:attribute][:value][:value] = 'gibberish' + end + + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) do + {email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', status_group: nil} + end + end + end + + context 'with a blank status group' do before do response_items.last[:attribute][:value][:value] = ' ' end - # The validations of the User model will take care - it 'passes' do - expect { userdata }.not_to raise_error + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) do + {email: 'john.oliver@example103.org', first_name: 'John', last_name: 'Oliver', status_group: nil} + end end end context 'with a missing attribute' do before do - response_items.pop + response_items.slice!(3) end - it 'raises an error' do - expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'AffiliationRole must not be empty') + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) do + {email: nil, first_name: 'John', last_name: 'Oliver', status_group: :educator} + end end end context 'without any provided attributes' do let(:response_items) { nil } - it 'raises an error' do - expect { userdata }.to raise_error(Enmeshed::ConnectorError, 'Could not parse userdata in the response items: ') + it_behaves_like 'parsed userdata' do + let(:parsed_userdata) { nil } end end end diff --git a/spec/requests/users/nbp_wallet/finalize_spec.rb b/spec/requests/users/nbp_wallet/finalize_spec.rb index adb85073d..45433f786 100644 --- a/spec/requests/users/nbp_wallet/finalize_spec.rb +++ b/spec/requests/users/nbp_wallet/finalize_spec.rb @@ -7,26 +7,17 @@ let(:uid) { 'example-uid' } let(:session_params) { {saml_uid: uid, omniauth_provider: 'nbp'} } + let(:relationship) { instance_double(Enmeshed::Relationship) } before do set_session(session_params) end context 'without any errors' do - let(:relationship) do - instance_double(Enmeshed::Relationship, - accept!: true, - userdata: { - email: 'john.oliver@example103.org', - first_name: 'john', - last_name: 'oliver', - status_group: 'learner', - }) - end - before do allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) - allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + allow(relationship).to receive_messages(peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', accept!: true, + userdata: {email: 'john.oliver@example103.org', first_name: 'john', last_name: 'oliver', status_group: :learner}) end it 'creates a user' do @@ -112,79 +103,69 @@ end context 'when an attribute is missing' do - # `Enmeshed::ConnectorError` is unknown until 'lib/enmeshed/connector.rb' is loaded, because it's defined there - require 'enmeshed/connector' - - let(:relationship) do - instance_double(Enmeshed::Relationship, accept!: false, reject!: true) - end - before do allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) - allow(relationship).to receive(:userdata).and_raise(Enmeshed::ConnectorError, 'EMailAddress must not be empty') + allow(relationship).to receive_messages( + userdata: {first_name: 'john', last_name: 'oliver', status_group: :learner}, + peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', + reject!: true + ) end - it_behaves_like 'a handled erroneous request', I18n.t('common.errors.generic') - it_behaves_like 'a documented erroneous request', Enmeshed::ConnectorError + it_behaves_like 'a handled erroneous request', "Could not create User: Email can't be blank" end context 'with an invalid status group' do - let(:relationship) do - instance_double(Enmeshed::Relationship, - reject!: true, - userdata: { - email: 'john.oliver@example103.org', - first_name: 'john', - last_name: 'oliver', - status_group: nil, - }) - end - before do allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive_messages(reject!: true, peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', userdata: { + email: 'john.oliver@example103.org', + first_name: 'john', + last_name: 'oliver', + status_group: nil, + }) end - it_behaves_like 'a handled erroneous request', 'Could not create User: Unknown role. Please select either ' \ - '"Teacher" or "Student" as your role.' + it_behaves_like 'a handled erroneous request', 'Could not create User: Status Group is unknown. ' \ + 'Please select either "Teacher" or "Student" as your role.' end - context 'when the User cannot be saved' do - let(:relationship) do - instance_double(Enmeshed::Relationship, - reject!: true, - userdata: { - email: 'john.oliver@example103.org', - first_name: 'john', - last_name: 'oliver', - status_group: 'learner', - }) + context 'with a blank name' do + before do + allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) + allow(relationship).to receive_messages( + userdata: {email: 'john.oliver@example103.org', first_name: ' ', last_name: 'oliver', status_group: :learner}, + peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', + reject!: true + ) end + it_behaves_like 'a handled erroneous request', "Could not create User: First Name can't be blank" + end + + context 'when email address already has been taken' do before do - create(:user, email: relationship.userdata[:email]) + create(:user, email: 'john.oliver@example103.org') allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) - allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + allow(relationship).to receive_messages( + userdata: {email: 'john.oliver@example103.org', first_name: 'john', last_name: 'oliver', status_group: :learner}, + peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', + reject!: true + ) end it_behaves_like 'a handled erroneous request', 'Could not create User: Email has already been taken' end context 'when the RelationshipChange cannot be accepted' do - let(:relationship) do - instance_double(Enmeshed::Relationship, - accept!: false, - reject!: true, - userdata: { - email: 'john.oliver@example103.org', - first_name: 'john', - last_name: 'oliver', - status_group: 'learner', - }) - end - before do allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(relationship) - allow(relationship).to receive(:peer).and_return('id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp') + allow(relationship).to receive_messages( + userdata: {email: 'john.oliver@example103.org', first_name: 'john', last_name: 'oliver', status_group: :learner}, + peer: 'id1EvvJ68x6wdHBwYrFTR31XtALHko9fnbyp', + accept!: false, + reject!: true + ) end it_behaves_like 'a handled erroneous request', I18n.t('common.errors.generic') From a394a4f381a64edc5e1300b47e167d279a4ca6db Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Thu, 13 Feb 2025 22:26:18 +0100 Subject: [PATCH 16/18] fix(nbp): clear session after successful finalize action Previously, the session was kept intact including the SAML information. This allowed users to return to the connect page, since they were still partially signed in. However, no visual indicator was shown for that state. We decided not to initiate the Single Log-Out via SAML or a regular logout due to the potentially increased complexity and interferences with the NBP IdP. --- app/controllers/users/nbp_wallet_controller.rb | 1 + spec/requests/users/nbp_wallet/finalize_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index dbf7bc5fc..4fd4e340d 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -63,6 +63,7 @@ def accept_and_create_user(relationship) # rubocop:disable Metrics/AbcSize if relationship.accept! user.send_confirmation_instructions + session.clear # Clear the session to prevent the user from accessing the NBP Wallet page again redirect_to home_index_path, notice: t('devise.registrations.signed_up_but_unconfirmed') else abort_and_refresh(relationship) diff --git a/spec/requests/users/nbp_wallet/finalize_spec.rb b/spec/requests/users/nbp_wallet/finalize_spec.rb index 45433f786..f9e3aa766 100644 --- a/spec/requests/users/nbp_wallet/finalize_spec.rb +++ b/spec/requests/users/nbp_wallet/finalize_spec.rb @@ -42,6 +42,11 @@ expect(User.order(:created_at).last).not_to be_confirmed end + it 'clears the session' do + finalize_request + expect(session.keys).to contain_exactly('flash') + end + it 'asks the user to verify the email address' do finalize_request expect(response).to redirect_to home_index_path @@ -72,6 +77,11 @@ it 'does not send a confirmation mail' do expect { finalize_request }.not_to change(ActionMailer::Base, :deliveries) end + + it 'does not clear the session' do + finalize_request + expect(session.keys).to include('flash', 'omniauth_provider', 'saml_uid', 'session_id') + end end shared_examples 'a documented erroneous request' do |error| From c64b9b1d42d6c50443260d1bb4ae0558becf9569 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Thu, 13 Feb 2025 22:30:25 +0100 Subject: [PATCH 17/18] fix(nbp): disallow access to NBP controller for existing users Previously, existing users signed up through NBP were allowed to view the connect page. With this change, only authenticated users not having an account yet are allowed to access the page. --- app/controllers/users/nbp_wallet_controller.rb | 3 +++ spec/requests/users/nbp_wallet/connect_spec.rb | 9 +++++++++ spec/requests/users/nbp_wallet/finalize_spec.rb | 9 +++++++++ spec/requests/users/nbp_wallet/qr_code_spec.rb | 9 +++++++++ .../users/nbp_wallet/relationship_status_spec.rb | 9 +++++++++ 5 files changed, 39 insertions(+) diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index 4fd4e340d..a05cffa66 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -86,6 +86,9 @@ def abort_and_refresh(relationship, reason = t('common.errors.generic')) def require_user! @provider_uid = session[:saml_uid] raise Pundit::NotAuthorizedError unless @provider_uid.present? && session[:omniauth_provider] == 'nbp' + # Already registered users should not be able to access this page + raise Pundit::NotAuthorizedError if User.joins(:identities) + .exists?(identities: {omniauth_provider: 'nbp', provider_uid: @provider_uid}) end end end diff --git a/spec/requests/users/nbp_wallet/connect_spec.rb b/spec/requests/users/nbp_wallet/connect_spec.rb index 3a2b44ad8..72972a401 100644 --- a/spec/requests/users/nbp_wallet/connect_spec.rb +++ b/spec/requests/users/nbp_wallet/connect_spec.rb @@ -82,6 +82,15 @@ it_behaves_like 'an unauthorized request' end + context 'with a session for a completed user' do + before do + User.new_from_omniauth(attributes_for(:user, status_group: :learner), 'nbp', uid).save! + connect_request + end + + it_behaves_like 'an unauthorized request' + end + context 'when the connector is down' do before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } diff --git a/spec/requests/users/nbp_wallet/finalize_spec.rb b/spec/requests/users/nbp_wallet/finalize_spec.rb index f9e3aa766..1d57943fa 100644 --- a/spec/requests/users/nbp_wallet/finalize_spec.rb +++ b/spec/requests/users/nbp_wallet/finalize_spec.rb @@ -103,6 +103,15 @@ it_behaves_like 'an unauthorized request' end + context 'with a session for a completed user' do + before do + User.new_from_omniauth(attributes_for(:user, status_group: :learner), 'nbp', uid).save! + finalize_request + end + + it_behaves_like 'an unauthorized request' + end + context 'when the connector is down' do before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } diff --git a/spec/requests/users/nbp_wallet/qr_code_spec.rb b/spec/requests/users/nbp_wallet/qr_code_spec.rb index 6afb92ab1..54f1122c2 100644 --- a/spec/requests/users/nbp_wallet/qr_code_spec.rb +++ b/spec/requests/users/nbp_wallet/qr_code_spec.rb @@ -43,4 +43,13 @@ it_behaves_like 'an unauthorized request' end + + context 'with a session for a completed user' do + before do + User.new_from_omniauth(attributes_for(:user, status_group: :learner), 'nbp', uid).save! + qr_code_request + end + + it_behaves_like 'an unauthorized request' + end end diff --git a/spec/requests/users/nbp_wallet/relationship_status_spec.rb b/spec/requests/users/nbp_wallet/relationship_status_spec.rb index f3e6ccdaf..7eb2a525c 100644 --- a/spec/requests/users/nbp_wallet/relationship_status_spec.rb +++ b/spec/requests/users/nbp_wallet/relationship_status_spec.rb @@ -48,6 +48,15 @@ it_behaves_like 'an unauthorized request' end + context 'with a session for a completed user' do + before do + User.new_from_omniauth(attributes_for(:user, status_group: :learner), 'nbp', uid).save! + relationship_status_request + end + + it_behaves_like 'an unauthorized request' + end + context 'when the connector is down' do before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_raise(Faraday::ConnectionFailed) } From 0311d5c31eb9efaa2e59672883606e96766d9f53 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Thu, 13 Feb 2025 22:50:04 +0100 Subject: [PATCH 18/18] fix(nbp): specify JSON as a default format for relationship_status This change is necessary to get proper error messages in case of (unhandled) exceptions. Previously, a `Pundit::NotAuthorizedError` would redirect to an HTML page. Now, such an exception with simply return a JSON with the proper status code of 401. Since we are using `fetch` with a path not containing any specific format, the previously used default would log `syntax error` messages in the JavaScript console (due to the followed redirect and the attempt to parse the then-received HTML as JSON). --- config/routes.rb | 2 +- .../users/nbp_wallet/relationship_status_spec.rb | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 4db6bab14..6e70bb38b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -36,7 +36,7 @@ collection do get '/nbp_wallet/connect', to: 'users/nbp_wallet#connect' get '/nbp_wallet/qr_code', to: 'users/nbp_wallet#qr_code' - get '/nbp_wallet/relationship_status', to: 'users/nbp_wallet#relationship_status' + get '/nbp_wallet/relationship_status', to: 'users/nbp_wallet#relationship_status', defaults: {format: :json} get '/nbp_wallet/finalize', to: 'users/nbp_wallet#finalize' end end diff --git a/spec/requests/users/nbp_wallet/relationship_status_spec.rb b/spec/requests/users/nbp_wallet/relationship_status_spec.rb index 7eb2a525c..a6c20dd62 100644 --- a/spec/requests/users/nbp_wallet/relationship_status_spec.rb +++ b/spec/requests/users/nbp_wallet/relationship_status_spec.rb @@ -12,6 +12,15 @@ set_session(session_params) end + shared_examples 'an unauthorized JSON request' do + expect_json + expect_http_status(:unauthorized) + + it 'returns a JSON with the error message' do + expect(response.parsed_body['error']).to eq I18n.t('common.errors.not_authorized') + end + end + context 'without errors' do context 'with a Relationship' do before { allow(Enmeshed::Relationship).to receive(:pending_for).with(uid).and_return(Enmeshed::Relationship) } @@ -45,7 +54,7 @@ relationship_status_request end - it_behaves_like 'an unauthorized request' + it_behaves_like 'an unauthorized JSON request' end context 'with a session for a completed user' do @@ -54,7 +63,7 @@ relationship_status_request end - it_behaves_like 'an unauthorized request' + it_behaves_like 'an unauthorized JSON request' end context 'when the connector is down' do