diff --git a/app/aggregates/peer.rb b/app/aggregates/peer.rb new file mode 100644 index 0000000..3b8f493 --- /dev/null +++ b/app/aggregates/peer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Aggregates + ## + # A +Peer+ is a remote actor. + class Peer + include EventSourcery::AggregateRoot + + AWAIT_SYNCING_VALUE = 'Synching...' + + attr_reader :name, :bio + + def initialize(id, events) + @name = AWAIT_SYNCING_VALUE + @bio = AWAIT_SYNCING_VALUE + super(id, events) + end + + apply PeerSynched do + end + end +end diff --git a/app/events/events.rb b/app/events/events.rb index 9ddc7bd..bc88a04 100644 --- a/app/events/events.rb +++ b/app/events/events.rb @@ -36,6 +36,14 @@ # A Member was Tagged MemberTagAdded = Class.new(EventSourcery::Event) +## +# Fetching of Details for peer from remote server requested. +PeerFetchRequested = Class.new(EventSourcery::Event) + +## +# Fetching of Details for peer finished +PeerSynched = Class.new(EventSourcery::Event) + ## # A Registration is confirmed RegistrationConfirmed = Class.new(EventSourcery::Event) diff --git a/app/reactors/contact_fetcher.rb b/app/reactors/contact_fetcher.rb new file mode 100644 index 0000000..1cbfd02 --- /dev/null +++ b/app/reactors/contact_fetcher.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Reactors + ## + # Adds an actor to a members' followers on various Events + class ContactFetcher + include EventSourcery::Postgres::Reactor + + processor_name :contact_fetcher + + emits_events PeerSynched + + process PeerFetchRequested do |event| + emit_event( + PeerSynched.new( + aggregate_id: event.aggregate_id, + body: event.body, + causation_id: event.uuid + ) + ) + end + end +end diff --git a/app/web/controllers/application_controller.rb b/app/web/controllers/application_controller.rb index ee09b23..f4d908f 100644 --- a/app/web/controllers/application_controller.rb +++ b/app/web/controllers/application_controller.rb @@ -35,6 +35,10 @@ def authorize(&block) raise Unauthorized unless block.call end + def authorized? + current_member.active? + end + def current_member return OpenStruct.new(active?: false) unless member_id diff --git a/app/web/controllers/web/contacts_controller.rb b/app/web/controllers/web/contacts_controller.rb index 0160296..deb61a0 100644 --- a/app/web/controllers/web/contacts_controller.rb +++ b/app/web/controllers/web/contacts_controller.rb @@ -17,7 +17,8 @@ class ContactsController < WebController # Add post '/contacts' do - requires_authorization + redirect '/remote' unless authorized? + authorize { may_add_contact? } Commands.handle( diff --git a/app/web/controllers/web/remote_confirmations_controller.rb b/app/web/controllers/web/remote_confirmations_controller.rb new file mode 100644 index 0000000..623333c --- /dev/null +++ b/app/web/controllers/web/remote_confirmations_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Web + ## + # Handles incoming redirects from remotes to confirm an action + class RemoteConfirmationsController < WebController + # TODO: authenticate and authorize + get '/remote_confirmation' do + erb( + :remote_confirmation, + layout: :layout_member, + locals: { + message: message, + form_action: form_action, + target: target, + taget_type: target_type + } + ) + end + + private + + # TODO: Once we have more actions, fetch this from signed attributes and + # pull through an allowlist + def form_action + 'contacts' + end + + # TODO: Unhardcode this message + def message + "As @harry@example.com you want to follow #{target}" + end + + # TODO: Fetch the target from attributes + def target + '@luna@ravenclaw.example.org' + end + + # TODO: Once we have more target types, fetch this from signed attributes + # and pull through an allowlist + def target_type + 'account' + end + end +end diff --git a/app/web/controllers/web/remote_controller.rb b/app/web/controllers/web/remote_controller.rb new file mode 100644 index 0000000..ba254cf --- /dev/null +++ b/app/web/controllers/web/remote_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Web + ## + # Handles Remote redirects + # TODO: sanitize and whitelist actions + # TODO: handle misparsed handles + # TODO: exchange server-server signed secrets with remote instance so that + # remote instance knows this request is coming and can validate it. + class RemoteController < WebController + get '/remote' do + erb( + :remote, + layout: :layout_anonymous, + locals: { + message: message, + action: action, + target: target, + taget_type: target_type + } + ) + end + + post '/remote' do + redirect remote_confirm_uri + end + + private + + def remote_confirm_uri + URI::HTTPS.build( + host: Handle.parse(params[:handle]).domain, + path: '/remote_confirmation', + query: URI.encode_www_form( + action: action, + target: target, + target_type: target_type + ) + ) + end + + def action + 'follow' + end + + def message + 'Provide your handle to follow @luna@ravenclaw.example.org' + end + + def target + '@luna.ravenclaw.example.org' + end + + def target_type + 'account' + end + end +end diff --git a/app/web/views/remote.erb b/app/web/views/remote.erb new file mode 100644 index 0000000..e0dd4b9 --- /dev/null +++ b/app/web/views/remote.erb @@ -0,0 +1,20 @@ +
diff --git a/app/web/views/remote_confirmation.erb b/app/web/views/remote_confirmation.erb new file mode 100644 index 0000000..857175d --- /dev/null +++ b/app/web/views/remote_confirmation.erb @@ -0,0 +1,7 @@ +<%= message %>
+ diff --git a/config.ru b/config.ru index f902766..b21093c 100644 --- a/config.ru +++ b/config.ru @@ -12,6 +12,9 @@ use Web::HomeController use Web::ProfilesController use Web::TagsController +use Web::RemoteController +use Web::RemoteConfirmationsController + use Web::LoginController # TODO: change from RPC alike "register" to "registration" use Web::RegistrationsController diff --git a/lib/handle.rb b/lib/handle.rb index fa9b8b6..b42d186 100644 --- a/lib/handle.rb +++ b/lib/handle.rb @@ -9,7 +9,7 @@ class Handle def initialize(username, handle_domain = Roost.config.domain, - local_domain = Roost.config.domain) + local_domain = handle_domain) @domain = handle_domain @local_domain = local_domain @username = username diff --git a/test/aggregates/peer_test.rb b/test/aggregates/peer_test.rb new file mode 100644 index 0000000..9b6154a --- /dev/null +++ b/test/aggregates/peer_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Aggregates + ## + # Unit test for the more complex logic in Peer Aggregate + class PeerTest < Minitest::Spec + let(:applied_events) { [] } + let(:aggregate_id) { fake_uuid(Aggregates::Peer, 1) } + + let(:subject) { Aggregates::Peer.new(aggregate_id, applied_events) } + + it '.name defaults to "Synching..."' do + assert_equal('Synching...', subject.name) + end + + it '.bio defaults to "Synching..."' do + assert_equal('Synching...', subject.bio) + end + end +end diff --git a/test/integration/web/federated_contacts_test.rb b/test/integration/web/federated_contacts_test.rb new file mode 100644 index 0000000..63ccdc0 --- /dev/null +++ b/test/integration/web/federated_contacts_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +## +# As a member using the web-app +# When I visit another members' profile +# And I click the "add to contacts" button +# Then the member is added to my contacts +class FederatedContactsTest < Minitest::WebSpec + before do + skip 'implement remote flow first' + harry + as(harry) + + # INK: remote_action. + adds_contact.upto(:contact_added) + end + + it 'adds another member to contacts' do + # NOTE that the handle uses .com and rons email .org + assert_content( + flash(:success), + 'ron@example.com was added to your contacts' + ) + + visit '/contacts' + assert_content('ron@example.com') + end +end diff --git a/test/integration/web/remote_action_test.rb b/test/integration/web/remote_action_test.rb new file mode 100644 index 0000000..0e36352 --- /dev/null +++ b/test/integration/web/remote_action_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'test_helper' + +## +# As a member of this.example.com instance +# When I visit other.example.com instance +# And I request an action there +# Then I am am presented with a form for my handle +# And when I fill that form and click "{action}" button +# Then I am redirected to my own instance with the proper payload +# So that I can finalize the action on my own instance +# +# NOTE: {action} is any of (but not limited to), add contact, tag, annotate, +# etc. +class RemoteActionTest < Minitest::WebSpec + let(:handle) { luna[:handle] } + + it 'adds remote member as contact' do + landing_path = at(ravenclaw) do + visit "/m/#{handle}" + click_icon('account-plus') + + the_label = "Provide your handle to follow #{handle}" + + page.find(:label, text: the_label) + fill_in(the_label, with: '@harry@example.com') + click_button('Follow') + page.current_path + end + + # Revisit the page to open it with harry as session + as(harry) + visit landing_path + + assert_content(page, "As @harry@example.com you want to follow #{handle}") + click_button('Confirm') + assert_content(flash(:success), "#{handle} was added to your contacts") + + process_events(%w[contact_fetch_requested contact_added]) + + visit '/contacts' + assert_content(luna[:handle]) + end + + # TODO: handle non-logged in on local. + # TODO: handle authentication properly with oauth secrets exchange. + + private + + def ravenclaw + @ravenclaw ||= RemoteInstance.new('ravenclaw.example.org', self) + end +end diff --git a/test/support/data_helpers.rb b/test/support/data_helpers.rb index 9289fab..e49498f 100644 --- a/test/support/data_helpers.rb +++ b/test/support/data_helpers.rb @@ -10,6 +10,20 @@ module DataHelpers 'Aggregates::Contact' => 4 }.freeze + def luna + return @_luna if @_luna + + @_luna = { + handle: '@luna@ravenclaw.example.org', + username: 'luna', + email: 'luna@ravenclaw.example.org', + password: 'secret' + } + member_registers(@_luna).upto(:confirmed).html + + @_luna + end + protected ## diff --git a/test/support/remote_helpers.rb b/test/support/remote_helpers.rb new file mode 100644 index 0000000..e8917b0 --- /dev/null +++ b/test/support/remote_helpers.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +## +# Helpers for testing events. +module RemoteHelpers + def at(instance, &block) + web_url_was = config.web_url + domain_was = config.domain + fqdn = instance.fqdn + + move_to_instance(URI::HTTP.build(host: fqdn, path: '').to_s, fqdn) + Capybara.using_session("on #{fqdn}") { instance.instance_eval(&block) } + ensure + move_to_instance(web_url_was, domain_was) + end + + private + + def move_to_instance(web_url, domain) + config.web_url = web_url + config.domain = domain + end + + def config + Roost.config + end + + # Test standin for a server, instance, that is external. + class RemoteInstance < SimpleDelegator + attr_reader :fqdn + + def initialize(fqdn, test_context) + @fqdn = fqdn + super(test_context) + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8cf0387..564123c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,6 +12,7 @@ require_relative 'support/file_helpers' require_relative 'support/mail_helpers' require_relative 'support/request_helpers' +require_relative 'support/remote_helpers' require_relative 'support/time_helpers' require_relative 'support/web_test_helpers' require_relative 'support/workflows' @@ -76,6 +77,7 @@ def app class WebSpec < Spec include Capybara::DSL include Capybara::Minitest::Assertions + include RemoteHelpers include WebTestHelpers def setup