diff --git a/events.json b/events.json index 247a75ad..894d92c4 100644 --- a/events.json +++ b/events.json @@ -116,7 +116,7 @@ "receives": ["All"], "emits": [ "Story.Step.Proceeded", - "Story.Step.Failed", + "Story.Step.Restarted", "Steppable (custom)" ] }, @@ -157,9 +157,6 @@ ], "Software.Software.LogForger": [ "Process.Created" - ], - "Story.Manager": [ - "Story.Proceeded" ] }, "process_conclusion": [ @@ -183,9 +180,13 @@ "Process.Created", "Process.Completed", "Process.Killed", + "File.Added", + "File.Deleted", "File.Uploaded", "StoryEmailSentEvent", "StoryReplySentEvent", + "Story.Step.Restarted", + "Story.Step.Proceeded", "Virus.Installed", "Virus.InstallFailed" ], @@ -196,9 +197,14 @@ "filters": ["Account.Created"], "emits": [] }, - "DownloadCrackerPublicFTP": { - "filters": ["File.Downloaded"], - "emits": [] + "DownloadCracker": { + "filters": [ + "File.Downloaded", + "File.Deleted" + ], + "emits": [ + "File.Added" + ] } } } diff --git a/lib/core/listener/model/owner.ex b/lib/core/listener/model/owner.ex index 84fd24d8..10f98850 100644 --- a/lib/core/listener/model/owner.ex +++ b/lib/core/listener/model/owner.ex @@ -45,6 +45,7 @@ defmodule Helix.Core.Listener.Model.Owner do %__MODULE__{} |> cast(params, @creation_fields) |> validate_required(@required_fields) + |> unique_constraint(:owner_id_object_id_event_subscriber) end defmodule Query do diff --git a/lib/event/dispatcher.ex b/lib/event/dispatcher.ex index d8ae4f52..c5c63aad 100644 --- a/lib/event/dispatcher.ex +++ b/lib/event/dispatcher.ex @@ -172,6 +172,7 @@ defmodule Helix.Event.Dispatcher do event SoftwareEvent.Cracker.Bruteforce.Processed event SoftwareEvent.Cracker.Overflow.Processed event SoftwareEvent.File.Added + event SoftwareEvent.File.Deleted event SoftwareEvent.File.Downloaded event SoftwareEvent.File.DownloadFailed event SoftwareEvent.File.Install.Processed @@ -235,6 +236,7 @@ defmodule Helix.Event.Dispatcher do event StoryEvent.Reply.Sent event StoryEvent.Step.ActionRequested event StoryEvent.Step.Proceeded + event StoryEvent.Step.Restarted # Custom handlers event StoryEvent.Reply.Sent, diff --git a/lib/hell/hack.ex b/lib/hell/hack.ex index f976ea3a..1cf7c01b 100644 --- a/lib/hell/hack.ex +++ b/lib/hell/hack.ex @@ -53,10 +53,11 @@ defmodule HELL.Hack.Experience do {:render, 3} ], "Elixir.Helix.Story.Model.Steppable" => [ - {:setup, 2}, + {:start, 1}, + {:setup, 1}, {:handle_event, 3}, {:complete, 1}, - {:fail, 1}, + {:restart, 3}, {:next_step, 1}, {:get_contact, 1}, {:format_meta, 1}, diff --git a/lib/software/event/file.ex b/lib/software/event/file.ex index 72ff745c..36a23f5c 100644 --- a/lib/software/event/file.ex +++ b/lib/software/event/file.ex @@ -50,6 +50,56 @@ defmodule Helix.Software.Event.File do end end + event Deleted do + @moduledoc """ + FileDeletedEvent is fired when a file has been deleted on the filesystem. + Most of the times is called as a result of FileDeleteProcessedEvent + """ + + alias Helix.Server.Model.Server + alias Helix.Software.Model.File + + @type t :: + %__MODULE__{ + file_id: File.id, + server_id: Server.id + } + + event_struct [:file_id, :server_id] + + @spec new(File.id, Server.id) :: + t + def new(file_id = %File.ID{}, server_id = %Server.ID{}) do + %__MODULE__{ + file_id: file_id, + server_id: server_id + } + end + + notify do + @moduledoc """ + Pushes the notification to the Client, so it can remove the deleted file. + """ + + @event :file_deleted + + def generate_payload(event, _socket) do + data = %{ + file_id: to_string(event.file_id) + } + + {:ok, data} + end + + def whom_to_notify(event), + do: %{server: [event.server_id]} + end + + listenable(event) do + [event.file_id] + end + end + event Downloaded do @moduledoc """ FileDownloadedEvent is fired when a FileTransfer process of type `download` diff --git a/lib/software/event/handler/filesystem.ex b/lib/software/event/handler/filesystem.ex index 07f577d0..f0579776 100644 --- a/lib/software/event/handler/filesystem.ex +++ b/lib/software/event/handler/filesystem.ex @@ -22,6 +22,7 @@ defmodule Helix.Software.Event.Handler.Filesystem do # Existing entries being updated # Existing entries being removed + # TODO FileDeletedEvent #384 # Generic notifiers diff --git a/lib/software/make/file.ex b/lib/software/make/file.ex index 3b7aa21e..be6ea8dd 100644 --- a/lib/software/make/file.ex +++ b/lib/software/make/file.ex @@ -7,6 +7,8 @@ defmodule Helix.Software.Make.File do alias Helix.Software.Model.Software alias Helix.Software.Model.Storage + alias Helix.Software.Event.File.Added, as: FileAddedEvent + @type modules :: cracker_modules @type cracker_modules :: %{bruteforce: version, overflow: version} @@ -16,31 +18,34 @@ defmodule Helix.Software.Make.File do @typep file_parent :: Server.t | Storage.id @typep version :: File.Module.version + @typep file_return(type) :: + {:ok, File.t_of_type(type), %{}, [FileAddedEvent.t]} + @doc """ Generates a cracker. """ @spec cracker(file_parent, cracker_modules, data) :: - {:ok, File.t_of_type(:cracker), %{}} + file_return(:cracker) def cracker(parent, modules, data \\ %{}), do: file(parent, :cracker, modules, data) @spec cracker!(file_parent, cracker_modules, data) :: File.t_of_type(:cracker) def cracker!(parent, modules, data \\ %{}) do - {:ok, file, _} = cracker(parent, modules, data) + {:ok, file, _, _} = cracker(parent, modules, data) file end @spec file(file_parent, Software.type, modules, data) :: - {:ok, File.t, %{}} + file_return(Software.type) defp file(server = %Server{}, type, modules, data) do server |> CacheQuery.from_server_get_storages!() |> List.first() - |> file(type, modules, data) + |> file(type, modules, data, server.server_id) end - defp file(storage_id = %Storage.ID{}, type, modules, data) do + defp file(storage_id = %Storage.ID{}, type, modules, data, server_id) do path = Map.get(data, :path, File.Default.path()) modules = format_modules(type, modules) @@ -56,7 +61,9 @@ defmodule Helix.Software.Make.File do {:ok, file} = FileAction.create(params, modules) - {:ok, file, %{}} + event = FileAddedEvent.new(file, server_id) + + {:ok, file, %{}, [event]} end @spec format_modules(Software.type, modules) :: diff --git a/lib/software/make/pftp.ex b/lib/software/make/pftp.ex index a6dd3893..ae1abe0c 100644 --- a/lib/software/make/pftp.ex +++ b/lib/software/make/pftp.ex @@ -6,18 +6,18 @@ defmodule Helix.Software.Make.PFTP do alias Helix.Software.Model.PublicFTP @spec server(Server.t) :: - {:ok, PublicFTP.t, %{}} + {:ok, PublicFTP.t, %{}, []} def server(server = %Server{}) do {:ok, pftp} = PublicFTPFlow.enable_server(server) - {:ok, pftp, %{}} + {:ok, pftp, %{}, []} end @spec add_file(File.t, PublicFTP.t) :: - {:ok, PublicFTP.File.t, %{}} + {:ok, PublicFTP.File.t, %{}, []} def add_file(file = %File{}, pftp = %PublicFTP{}) do {:ok, pftp_file} = PublicFTPFlow.add_file(pftp, file) - {:ok, pftp_file, %{}} + {:ok, pftp_file, %{}, []} end end diff --git a/lib/software/model/public_ftp.ex b/lib/software/model/public_ftp.ex index 1ee745ad..0b85f10f 100644 --- a/lib/software/model/public_ftp.ex +++ b/lib/software/model/public_ftp.ex @@ -79,6 +79,14 @@ defmodule Helix.Software.Model.PublicFTP do def disable_server(changeset = %Changeset{}), do: put_change(changeset, :is_active, false) + @spec is_active?(t) :: + boolean + @doc """ + Verifies whether the given server is active (enabled) or not. + """ + def is_active?(%__MODULE__{is_active: is_active?}), + do: is_active? + @spec create_changeset(creation_params) :: changeset defp create_changeset(params) do diff --git a/lib/software/query/public_ftp.ex b/lib/software/query/public_ftp.ex index abdcf553..7937569f 100644 --- a/lib/software/query/public_ftp.ex +++ b/lib/software/query/public_ftp.ex @@ -5,7 +5,7 @@ defmodule Helix.Software.Query.PublicFTP do alias Helix.Software.Model.File alias Helix.Software.Model.PublicFTP - @spec fetch_file(File.t) :: + @spec fetch_file(File.idt) :: PublicFTP.File.t | nil @doc """ @@ -15,9 +15,11 @@ defmodule Helix.Software.Query.PublicFTP do - The PublicFTP server is active. """ def fetch_file(file = %File{}), - do: PublicFTPInternal.fetch_file(file.file_id) + do: fetch_file(file.file_id) + def fetch_file(file_id = %File.ID{}), + do: PublicFTPInternal.fetch_file(file_id) - @spec fetch_server(Server.t) :: + @spec fetch_server(Server.idt) :: PublicFTP.t | nil @doc """ @@ -26,7 +28,9 @@ defmodule Helix.Software.Query.PublicFTP do Disabled/inactive servers are returned as well. """ def fetch_server(server = %Server{}), - do: PublicFTPInternal.fetch(server.server_id) + do: fetch_server(server.server_id) + def fetch_server(server_id = %Server.ID{}), + do: PublicFTPInternal.fetch(server_id) @spec list_files(Server.idt) :: [File.t] diff --git a/lib/story/action/flow/story.ex b/lib/story/action/flow/story.ex index 287eadf2..2f3b4044 100644 --- a/lib/story/action/flow/story.ex +++ b/lib/story/action/flow/story.ex @@ -30,7 +30,7 @@ defmodule Helix.Story.Action.Flow.Story do with \ {:ok, _} <- ContextFlow.setup(entity), {:ok, story_step} <- StoryAction.proceed_step(first_step), - {:ok, _, events} <- Steppable.setup(first_step, nil), + {:ok, _, events} <- Steppable.start(first_step), on_success(fn -> Event.emit(events, from: relay) end) do {:ok, story_step} diff --git a/lib/story/action/story.ex b/lib/story/action/story.ex index 6cdf1f53..68d9425b 100644 --- a/lib/story/action/story.ex +++ b/lib/story/action/story.ex @@ -9,6 +9,7 @@ defmodule Helix.Story.Action.Story do alias Helix.Story.Event.Email.Sent, as: EmailSentEvent alias Helix.Story.Event.Reply.Sent, as: ReplySentEvent alias Helix.Story.Event.Step.Proceeded, as: StepProceededEvent + alias Helix.Story.Event.Step.Restarted, as: StepRestartedEvent @spec proceed_step(first_step :: Step.t) :: {:ok, Story.Step.t} @@ -64,6 +65,15 @@ defmodule Helix.Story.Action.Story do def notify_step(prev_step, next_step), do: [StepProceededEvent.new(prev_step, next_step)] + @spec notify_restart(Step.t, atom, Step.email_id, Step.email_meta) :: + [StepRestartedEvent.t] + @doc """ + Generates the StepRestartedEvent, used to notify the client that the step has + been restarted + """ + def notify_restart(step, reason, checkpoint, meta), + do: [StepRestartedEvent.new(step, reason, checkpoint, meta)] + @spec send_email(Step.t, Step.email_id, Step.email_meta) :: {:ok, [EmailSentEvent.t]} | {:error, :internal} @@ -122,4 +132,38 @@ defmodule Helix.Story.Action.Story do end end) end + + @spec rollback_emails(Step.t, Step.email_id, Step.email_meta) :: + {:ok, Story.Step.t, Story.Email.t} + | {:error, :internal} + @doc """ + Rollbacks the messages on `step` to the specified `checkpoint`. + + Note that within the Story domain, messages are saved on two places: + - within Story.Step, used for internal step stuff (handling replies, etc) + - within Story.Email, used for listing messages per contact (with metadata) + + As such, we need to update (rollback) to the checkpoint on both places. + + The `allowed_replies` list, specified at `Story.Step.t`, will also be updated + with the default (unlocked) replies listed on `checkpoint` declaration. + """ + def rollback_emails(step, checkpoint, meta) do + result = + Repo.transaction fn -> + with \ + {:ok, story_step} <- StepInternal.rollback_email(step, checkpoint), + {:ok, email} <- EmailInternal.rollback_email(step, checkpoint, meta) + do + {story_step, email} + else + _ -> + Repo.rollback(:internal) + end + end + + with {:ok, {story_step, story_email}} <- result do + {:ok, story_step, story_email} + end + end end diff --git a/lib/story/event/handler/story.ex b/lib/story/event/handler/story.ex index 590c15c7..e3384926 100644 --- a/lib/story/event/handler/story.ex +++ b/lib/story/event/handler/story.ex @@ -6,7 +6,7 @@ defmodule Helix.Story.Event.Handler.Story do Once an event is received, we figure out the entity responsible for that event and verify whether the StepFlow should be followed. The StepFlow guides the Step through the Steppable protocol, allow it to react to the event, either - by ignoring it, completing the step or failing it. + by ignoring it, completing the step or restarting it. """ import HELF.Flow @@ -30,7 +30,7 @@ defmodule Helix.Story.Event.Handler.Story do Emits: - Events returned by `Steppable` methods - StepProceededEvent.t when action is :complete - - StepFailedEvent.t, StepRestartedEvent.t when action is :fail + - StepRestartedEvent.t when action is :restart """ def event_handler(event) do with \ @@ -45,6 +45,10 @@ defmodule Helix.Story.Event.Handler.Story do end end + @doc """ + Handler for `StepActionRequestedEvent`, directly relaying the requested action + to the corresponding handler at `handle_action/2`. + """ def action_handler(event = %StepActionRequestedEvent{}) do with \ %{object: step} <- @@ -63,7 +67,7 @@ defmodule Helix.Story.Event.Handler.Story do its documentation for more information. Once the event is handled by the step, the returned action is handled by - StepFlow. It may be one of `:complete | :fail | :noop`. See doc on + StepFlow. It may be one of `:complete | {:restart, _, _} | :noop`. See doc on `handle_action/2`. """ defp step_flow(step) do @@ -71,7 +75,10 @@ defmodule Helix.Story.Event.Handler.Story do with \ {action, step, events} <- Steppable.handle_event(step, step.event, step.meta), - on_success(fn -> Event.emit(events, from: step.event) end), + + # HACK: Workaround for HELF #29 + # on_success(fn -> Event.emit(events, from: step.event) end), + Event.emit(events, from: step.event), handle_action(action, step) do :ok @@ -79,7 +86,7 @@ defmodule Helix.Story.Event.Handler.Story do end end - @spec handle_action(Steppable.actions, Step.t) :: + @spec handle_action(Step.callback_action, Step.t) :: term docp """ When a step requests to be completed, we'll call `Steppable.complete/1`, @@ -97,16 +104,17 @@ defmodule Helix.Story.Event.Handler.Story do end docp """ - If the request is to fail/abort an step, we'll call `Steppable.fail/1`, - and then handle the failure with `fail_step/1` + If the request is to restart a step, we'll call `Steppable.restart/3`, and + then handle the restart with `restart_step/1`. """ - defp handle_action(:fail, step) do - with {:ok, step, events} <- Steppable.fail(step) do - Event.emit(events, from: step.event) + defp handle_action({:restart, reason, checkpoint}, step) do + with \ + {:ok, step, meta, events} <- Steppable.restart(step, reason, checkpoint), + Event.emit(events, from: step.event), - hespawn fn -> - fail_step(step) - end + :ok <- restart_step(step, reason, checkpoint, meta) + do + :ok end end @@ -121,8 +129,8 @@ defmodule Helix.Story.Event.Handler.Story do docp """ Updates the database, so that the player gets moved to the next step. - This is where we call next step's `Steppable.setup`, as well as the - `StepProceededEvent` is sent to the client + This is where we call next step's `Steppable.start`, as well as make sure the + `StepProceededEvent` is sent to the client. Emits: StepProceededEvent.t """ @@ -135,7 +143,7 @@ defmodule Helix.Story.Event.Handler.Story do # /\ Proceeds player to the next step # Generate next step data/meta - {:ok, next_step, events} <- Steppable.setup(next_step, prev_step), + {:ok, next_step, events} <- Steppable.start(next_step), Event.emit(events, from: prev_step.event), # Update step meta @@ -150,31 +158,28 @@ defmodule Helix.Story.Event.Handler.Story do end docp """ - See comments & implement me. + Updates the database, so the step restart is persisted. + + It will: + - update the DB with the new step metadata + - rollback the emails to the specified checkpoint + - notify the client that the step has been restarted - Emits: StepFailedEvent.t, StepRestartedEvent.t + Emits: StepRestartedEvent.t """ - defp fail_step(_step) do - # Default fail_step implementation is TODO. - # Possible implementation: - # 1 - Remove all emails/replies sent through that step - # 2 - Undo/delete all objects generated on `Steppable.setup`* - # 3 - Call `Steppable.setup`, effectively restarting the step. - # - # Possible problems: - # 1 - Email/reply ids are not unique across steps, so step 1 should take - # this into consideration. - # /\ - add counter of emails sent during the current step - # - # 2 - UX: If mission is reset right after it's failed, the client may - # receive the `stepproceeded**` event almost at the same time as - # `stepfailed` event, so user experience should be considered - # /\ - see note; use `StepRestartedEvent` - # - # Notes: - # * - This should be done at `Steppable.fail` - # ** - In fact, mission "resetup" should be a different event, maybe - # `StepRestarted`. Otherwise, the client would get `StepProceeded` after - # the step has failed, which doesn't quite make sense. + defp restart_step(step, reason, checkpoint, meta) do + with \ + {:ok, _} <- StoryAction.update_step_meta(step), + # /\ Make sure the step metadata is updated on the DB + + # Rollback to the specified checkpoint + {:ok, _, _} <- StoryAction.rollback_emails(step, checkpoint, meta), + + # Notify about step restart + event = StoryAction.notify_restart(step, reason, checkpoint, meta), + Event.emit(event, from: step.event) + do + :ok + end end end diff --git a/lib/story/event/step.ex b/lib/story/event/step.ex index 2bd1ee31..b152f15d 100644 --- a/lib/story/event/step.ex +++ b/lib/story/event/step.ex @@ -52,6 +52,70 @@ defmodule Helix.Story.Event.Step do end end + event Restarted do + @moduledoc """ + Story.StepRestarted is fired when the step progress has been restarted due + to some `reason`. + """ + + alias Helix.Entity.Model.Entity + alias Helix.Story.Model.Step + + @type t :: + %__MODULE__{ + entity_id: Entity.id, + step: Step.t, + reason: atom, + checkpoint: Step.email_id, + meta: Step.email_meta, + } + + event_struct [:entity_id, :step, :reason, :checkpoint, :meta] + + @spec new(Step.t, atom, Step.email_id, Step.email_meta) :: + t + def new(step = %_{entity_id: _}, reason, checkpoint, meta) do + %__MODULE__{ + entity_id: step.entity_id, + step: step, + reason: reason, + checkpoint: checkpoint, + meta: meta + } + end + + notify do + @moduledoc false + + alias HELL.Utils + + @event :story_step_restarted + + def generate_payload(event, _socket) do + allowed_replies = + event.step + |> Step.get_replies(event.checkpoint) + |> Enum.map(&to_string/1) + + data = %{ + step: to_string(event.step.name), + reason: to_string(event.reason), + checkpoint: event.checkpoint, + meta: Utils.stringify_map(event.meta), + allowed_replies: allowed_replies + } + + {:ok, data} + end + + @doc """ + Notifies only the player + """ + def whom_to_notify(event), + do: %{account: event.entity_id} + end + end + event ActionRequested do @moduledoc """ `StepActionRequestedEvent` is fired when a callback, declared at the Step diff --git a/lib/story/internal/email.ex b/lib/story/internal/email.ex index e7860ca6..9238d343 100644 --- a/lib/story/internal/email.ex +++ b/lib/story/internal/email.ex @@ -11,12 +11,17 @@ defmodule Helix.Story.Internal.Email do {:ok, Story.Email.t} | {:error, Story.Email.changeset} - @spec fetch(Entity.id, Step.contact) :: + @typep fetch_result :: Story.Email.t | nil + + @spec fetch(Step.t) :: fetch_result + @spec fetch(Entity.id, Step.contact) :: fetch_result @doc """ Fetches the (entity, contact) entry, formatting it as required """ + def fetch(%_{entity_id: entity_id, contact: contact_id}), + do: fetch(entity_id, contact_id) def fetch(entity_id, contact_id) do entry = entity_id @@ -59,6 +64,18 @@ defmodule Helix.Story.Internal.Email do def send_reply(step, reply_id), do: generic_send(step, reply_id, :player) + @spec rollback_email(Step.t, Step.email_id, Step.email_meta) :: + entry_email_repo_return + @doc """ + Rollbacks the email history to the given checkpoint (`email_id`). + """ + def rollback_email(step, email_id, meta) do + step + |> fetch() + |> Story.Email.rollback_email(email_id, meta) + |> update() + end + @spec generic_send(term, id :: String.t, Story.Email.sender, meta :: map) :: {:ok, Story.Email.t, Story.Email.email} | :internal_error @@ -119,6 +136,11 @@ defmodule Helix.Story.Internal.Email do |> Repo.update_all([], returning: true) end + @spec update(Story.Email.changeset) :: + entry_email_repo_return + defp update(changeset), + do: Repo.update(changeset) + @spec format(Story.Email.t) :: Story.Email.t docp """ diff --git a/lib/story/internal/step.ex b/lib/story/internal/step.ex index ab403aa4..8c7c04b7 100644 --- a/lib/story/internal/step.ex +++ b/lib/story/internal/step.ex @@ -74,8 +74,7 @@ defmodule Helix.Story.Internal.Step do end) end - @spec proceed(first_step :: Step.t) :: - entry_step_repo_return + @spec proceed(first_step :: Step.t) :: entry_step_repo_return @spec proceed(prev_step :: Step.t, next_step :: Step.t) :: {:ok, Story.Step.t} | {:error, :internal} @@ -159,6 +158,39 @@ defmodule Helix.Story.Internal.Step do |> update() end + @spec rollback_email(Step.t, Step.email_id) :: + entry_step_repo_return + @doc """ + Rollbacks the Story.Step emails to the specified checkpoint. + """ + def rollback_email(step, checkpoint) do + replies = Step.get_replies(step, checkpoint) + + step + |> fetch!() + |> Story.Step.rollback_email(checkpoint, replies) + |> update() + end + + @spec gather_data(Story.Step.t, Story.Manager.t) :: + step_info + docp """ + Helper that retrieves the `Step.t` based on the `story_step` + """ + defp gather_data(story_step = %Story.Step{}, manager = %Story.Manager{}) do + step = + Step.fetch( + story_step.step_name, story_step.entity_id, story_step.meta, manager + ) + + formatted_meta = Step.format_meta(step) + + %{ + object: %{step| meta: formatted_meta}, + entry: %{story_step| meta: formatted_meta} + } + end + @spec create(Step.t) :: entry_step_repo_return defp create(step) do @@ -186,23 +218,4 @@ defmodule Helix.Story.Internal.Step do :ok end - - @spec gather_data(Story.Step.t, Story.Manager.t) :: - step_info - docp """ - Helper that retrieves the `Step.t` based on the `story_step` - """ - defp gather_data(story_step = %Story.Step{}, manager = %Story.Manager{}) do - step = - Step.fetch( - story_step.step_name, story_step.entity_id, story_step.meta, manager - ) - - formatted_meta = Step.format_meta(step) - - %{ - object: %{step| meta: formatted_meta}, - entry: %{story_step| meta: formatted_meta} - } - end end diff --git a/lib/story/make/story.ex b/lib/story/make/story.ex index a9eadf04..9c17f31a 100644 --- a/lib/story/make/story.ex +++ b/lib/story/make/story.ex @@ -13,7 +13,7 @@ defmodule Helix.Story.Make.Story do @typep char_related :: %{entity: Entity.t} @spec char(Network.id) :: - {:ok, Server.t, char_related} + {:ok, Server.t, char_related, []} def char(network_id = %Network.ID{}) do network = NetworkQuery.fetch(network_id) net_data = @@ -28,7 +28,7 @@ defmodule Helix.Story.Make.Story do {:ok, entity, _} <- MakeEntity.entity(npc), {:ok, server, _} <- MakeServer.npc(entity, net_data) do - {:ok, server, %{entity: entity}} + {:ok, server, %{entity: entity}, []} end end end diff --git a/lib/story/mission/tutorial/steps.ex b/lib/story/mission/tutorial/steps.ex index 1c491a66..331ebb1e 100644 --- a/lib/story/mission/tutorial/steps.ex +++ b/lib/story/mission/tutorial/steps.ex @@ -6,14 +6,22 @@ defmodule Helix.Story.Mission.Tutorial do step SetupPc do - email "welcome_pc_setup", + email "welcome", reply: "back_thanks" on_reply "back_thanks", + send: "watchiadoing" + + email "watchiadoing", + reply: "hell_yeah" + + on_reply "hell_yeah", :complete - def setup(step, _) do - e1 = send_email step, "welcome_pc_setup" + empty_setup() + + def start(step) do + e1 = send_email step, "welcome" {:ok, step, e1} end @@ -22,10 +30,10 @@ defmodule Helix.Story.Mission.Tutorial do {:ok, step, []} end - next_step Helix.Story.Mission.Tutorial.DownloadCrackerPublicFtp + next_step Helix.Story.Mission.Tutorial.DownloadCracker end - step DownloadCrackerPublicFtp do + step DownloadCracker do alias Helix.Server.Model.Server alias Helix.Server.Query.Server, as: ServerQuery @@ -33,11 +41,12 @@ defmodule Helix.Story.Mission.Tutorial do alias Helix.Story.Action.Context, as: ContextAction alias Helix.Software.Event.File.Downloaded, as: FileDownloadedEvent + alias Helix.Software.Event.File.Deleted, as: FileDeletedEvent alias Helix.Software.Make.File, as: MakeFile alias Helix.Software.Make.PFTP, as: MakePFTP - email "download_cracker_public_ftp", + email "download_cracker1", reply: ["more_info"], locked: ["sure"] @@ -48,18 +57,39 @@ defmodule Helix.Story.Mission.Tutorial do on_reply "more_info", send: "give_more_info" - def setup(step, _) do - {:ok, server, %{entity: entity}} = StoryMake.char(step.manager.network_id) + def setup(step) do + # Create the underlying character (@contact) and its server + {:ok, server, _, e1} = + setup_once :char, {step.entity_id, @contact} do + result = {:ok, server, %{entity: entity}, _} = + StoryMake.char(step.manager.network_id) - ContextAction.save(step.entity_id, @contact, :server_id, server.server_id) - ContextAction.save(step.entity_id, @contact, :entity_id, entity.entity_id) + ContextAction.save( + step.entity_id, @contact, :server_id, server.server_id + ) + ContextAction.save( + step.entity_id, @contact, :entity_id, entity.entity_id + ) - # Create the Cracker the player is supposed to download - cracker = MakeFile.cracker!(server, %{bruteforce: 10, overflow: 10}) + result + end - # Enable a PFTP server and put the cracker in it - {:ok, pftp, _} = MakePFTP.server(server) - MakePFTP.add_file(cracker, pftp) + # Create the Cracker the player is supposed to download + {:ok, cracker, _, e2} = + setup_once :file, step.meta[:cracker_id] do + MakeFile.cracker(server, %{bruteforce: 10, overflow: 10}) + end + + # Enable the PFTP server and put the cracker in it + {:ok, pftp, _, e3} = + setup_once :pftp_server, server do + MakePFTP.server(server) + end + + {:ok, _, _, e4} = + setup_once :pftp_file, cracker do + MakePFTP.add_file(cracker, pftp) + end ip = ServerQuery.get_ip(server, step.manager.network_id) @@ -70,16 +100,34 @@ defmodule Helix.Story.Mission.Tutorial do cracker_id: cracker.file_id } - # Callbacks + # Listeners + hespawn fn -> - # React to the moment the cracker is downloaded - story_listen cracker.file_id, FileDownloadedEvent, do: :complete + # React to the moment the cracker is downloaded + story_listen cracker.file_id, FileDownloadedEvent, do: :complete - e1 = send_email step, "download_cracker_public_ftp", %{ip: ip} + story_listen cracker.file_id, FileDeletedEvent, :on_file_deleted + end - step = %{step|meta: meta} + events = e1 ++ e2 ++ e3 ++ e4 - {:ok, step, e1} + {meta, %{}, events} + end + + # Callbacks + + callback :on_file_deleted, _event do + {{:restart, :file_deleted, "download_cracker1"}, []} + end + + def start(step) do + {meta, _, e1} = setup(step) + + e2 = send_email step, "download_cracker1", %{ip: meta.ip} + + step = %{step| meta: meta} + + {:ok, step, e1 ++ e2} end format_meta do @@ -94,6 +142,12 @@ defmodule Helix.Story.Mission.Tutorial do {:ok, step, []} end - next_step __MODULE__ + def restart(step, _reason, _checkpoint) do + {meta, _, e1} = setup(step) + + {:ok, %{step| meta: meta}, %{ip: meta.ip}, e1} + end + + next_step Helix.Story.Mission.Tutorial.SetupPc end end diff --git a/lib/story/model/step.ex b/lib/story/model/step.ex index bf4dfa35..edefb6a4 100644 --- a/lib/story/model/step.ex +++ b/lib/story/model/step.ex @@ -49,11 +49,19 @@ defmodule Helix.Story.Model.Step do @type contact :: Constant.t @type contact_id :: contact + @typedoc """ + The `callback_action` type lists all possible actions that may be applied to + a step. Notably, one of them must be returned by `Steppable.handle_event/3`, + but it's also used in other contexts, including on `StepActionRequestedEvent`. + + The action will be interpreted and applied at the StoryHandler. + + Note that `:restart` also includes metadata (`reason` and `checkpoint`). + """ @type callback_action :: :complete - | :fail - | :regenerate | :noop + | {:restart, reason :: atom, checkpoint :: email_id} @spec new(t, Event.t) :: t diff --git a/lib/story/model/step/macros.ex b/lib/story/model/step/macros.ex index 6dfb5b17..e48f0b84 100644 --- a/lib/story/model/step/macros.ex +++ b/lib/story/model/step/macros.ex @@ -14,6 +14,7 @@ defmodule Helix.Story.Model.Step.Macros do alias Helix.Entity.Model.Entity alias Helix.Story.Model.Step alias Helix.Story.Action.Story, as: StoryAction + alias Helix.Story.Query.Story, as: StoryQuery alias Helix.Story.Event.Reply.Sent, as: StoryReplySentEvent alias Helix.Story.Event.Step.ActionRequested, as: StepActionRequestedEvent @@ -30,21 +31,22 @@ defmodule Helix.Story.Model.Step.Macros do defimpl Helix.Story.Model.Steppable do @moduledoc false + import HELL.Macros + alias Helix.Event alias Helix.Story.Make.Story, as: StoryMake @emails Module.get_attribute(__MODULE__, :emails) || %{} @contact get_contact(unquote(contact), __MODULE__) @step_name Helix.Story.Model.Step.get_name(unquote(name)) - @set false unquote(block) - # Most steps do not have a "fail" option. Those who do must manually - # implement this protocol function. + # Most steps do not have a "restart" option. Those who do must + # manually implement this protocol function. @doc false - def fail(_step), - do: raise "Undefined fail handler at #{inspect unquote(__MODULE__)}" + def restart(_step, _, _), + do: raise "Undefined restart handler at #{inspect unquote(__MODULE__)}" # Catch-all for unhandled events, otherwise any unexpected event would # thrown an exception here. @@ -60,23 +62,19 @@ defmodule Helix.Story.Model.Step.Macros do def get_contact(_), do: @contact - # Unlocked replies only + @spec get_replies(Step.t, Step.email_id) :: + [Step.reply_id] @doc false + # Unlocked replies only def get_replies(_step, email_id) do - case Map.get(@emails, email_id) do - email = %{} -> - email.replies - nil -> - [] + with email = %{} <- Map.get(@emails, email_id, []) do + email.replies end end - @spec handle_callback(Step.callback_action, Entity.id, Step.contact) :: - {:ok, [Event.t]} - defp handle_callback(action, entity_id, contact) when not is_tuple(action), - do: handle_callback({action, []}, entity_id, contact) - - @spec handle_callback({Step.callback_action, [Event.t]}, Entity.id, Step.contact) :: + @spec handle_callback( + {Step.callback_action, [Event.t]}, Entity.id, Step.contact) + :: {:ok, [Event.t]} defp handle_callback({action, events}, entity_id, contact_id) do request_action = @@ -87,17 +85,7 @@ defmodule Helix.Story.Model.Step.Macros do @doc false callback :callback_complete do - :complete - end - - @doc false - callback :callback_fail do - :fail - end - - @doc false - callback :callback_regenerate do - :regenerate + {:complete, []} end end end @@ -116,9 +104,9 @@ defmodule Helix.Story.Model.Step.Macros do do quote do - def unquote(name)(var!(event) = unquote(event), m = unquote(meta)) do - step_entity_id = m["step_entity_id"] |> Entity.ID.cast!() - step_contact_id = m["step_contact_id"] |> String.to_existing_atom() + def unquote(name)(var!(event) = unquote(event), meta = unquote(meta)) do + step_entity_id = meta["step_entity_id"] |> Entity.ID.cast!() + step_contact_id = meta["step_contact_id"] |> String.to_existing_atom() var!(event) # Mark as used @@ -129,6 +117,9 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Executes a predefined callback (currently only `:complete` is supported). + """ defmacro story_listen(element_id, events, do: action) do quote do @@ -145,6 +136,7 @@ defmodule Helix.Story.Model.Step.Macros do It's a wrapper for `Core.Listener`. """ defmacro story_listen(element_id, events, callback) do + # Import `Helix.Core.Listener` only once within the Step context (ENV) macro = has_macro?(__CALLER__, Helix.Core.Listener) import_block = macro && [] || quote(do: import Helix.Core.Listener) @@ -163,12 +155,18 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Formats the step metadata, automatically handling empty maps or atomizing + existing map keys. + """ defmacro format_meta(do: block) do quote do + @doc false def format_meta(%{meta: empty_map}) when empty_map == %{}, do: %{} + @doc false def format_meta(%{meta: meta}) do var!(meta) = HELL.MapUtils.atomize_keys(meta) unquote(block) @@ -177,6 +175,11 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Public interface that should be used by the step to point to the next one. + + Steps are linked lists. Mind == blown. + """ defmacro next_step(next_step_module) do quote do # unless Code.ensure_compiled?(unquote(next_step_module)) do @@ -190,7 +193,7 @@ defmodule Helix.Story.Model.Step.Macros do # I don't want neither 3 or 4. Waiting for a cool hack on 1 or 2. @doc """ - Returns the next step module name. + Returns the next step module name (#{inspect unquote(next_step_module)}). """ def next_step(_), do: Helix.Story.Model.Step.get_name(unquote(next_step_module)) @@ -254,9 +257,9 @@ defmodule Helix.Story.Model.Step.Macros do {:complete, step, []} end - [fail: true] -> + [restart: true, reason: reason, checkpoint: checkpoint] -> quote do - {:fail, step, []} + {{:restart, unquote(reason), unquote(checkpoint)}, step, []} end end ) @@ -265,6 +268,9 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Interface used to declare what should happen when `reply_id` is received. + """ defmacro on_reply(reply_id, opts) do # Emails that can receive this reply emails = get_emails(__CALLER__) @@ -288,7 +294,7 @@ defmodule Helix.Story.Model.Step.Macros do end @doc """ - Below macro is required so the elixir compiler does not complain about the + This macro is required so the elixir compiler does not complain about the module attribute not being used. """ defmacro contact(contact_name) do @@ -297,6 +303,43 @@ defmodule Helix.Story.Model.Step.Macros do end end + @doc """ + Helper (syntactic sugar) for steps that do not generate any data. + """ + defmacro empty_setup do + quote do + + @doc false + def setup(_) do + nil + end + + end + end + + @doc """ + `setup_once` is a helper to ease achieving idempotency on `Steppable.setup/1`. + + It's a thin wrapper around `StoryQuery.Setup`, which does the heavy work. + """ + defmacro setup_once(object, identifier, do: block), + do: do_setup_once(object, identifier, [], block) + defmacro setup_once(object, identifier, opts, do: block), + do: do_setup_once(object, identifier, opts, block) + + defp do_setup_once(object, id, opts, block) do + fun_name = Utils.concat_atom(:find_, object) + + quote do + result = + apply(StoryQuery.Setup, unquote(fun_name), [unquote(id), unquote(opts)]) + + with nil <- result do + unquote(block) + end + end + end + @spec get_contact(String.t | Constant.t | nil, module :: term) :: contact :: Step.contact @doc """ diff --git a/lib/story/model/step/macros/setup.ex b/lib/story/model/step/macros/setup.ex new file mode 100644 index 00000000..1cdca622 --- /dev/null +++ b/lib/story/model/step/macros/setup.ex @@ -0,0 +1,71 @@ +defmodule Helix.Story.Model.Step.Macros.Setup do + + @doc """ + The `find` macro will generate the standard `find_{item}` method. It's mostly + a syntactic sugar that: + + - Automatically returns `nil` if the `identifier` is `nil` + - Automatically handles identifiers that may be `id` or `t`, fetching the + requested field (defined on `get`) + - Automatically converts the result to `nil` from equivalent results (e.g. + `false` is considered to be `nil` here). This is useful because the macro + that uses `StoryQuery.Setup` only accepts `nil` as a valid negative result. + + Other than that, feel free to bypass the macro and write the function directly + + It only has to: + + - have `find_{item}` name + - accept `identifier` and `opts` + - return `nil` in case of failure + - return the expected format ({:ok, $object, $related, $events}) if found + """ + defmacro find(item, identifier, get: field), + do: do_find(item, identifier, quote(do: []), field: field) + defmacro find(item, identifier, do: block), + do: do_find(item, identifier, quote(do: []), block: block) + defmacro find(item, identifier, opts, get: field), + do: do_find(item, identifier, opts, field: field) + defmacro find(item, identifier, opts, do: block), + do: do_find(item, identifier, opts, block: block) + + defp do_find(item, identifier, opts, field: field) do + quote do + + def unquote(:"find_#{item}")(id = unquote(identifier), opts = unquote(opts)) do + value = Map.fetch!(id, unquote(field)) + + apply(__MODULE__, unquote(:"find_#{item}"), [value, opts]) + end + + end + end + + defp do_find(item, identifier, opts, block: block) do + quote generated: true do + + @spec unquote(:"find_#{item}")(nil, list) :: nil + + def unquote(:"find_#{item}")(nil, _), + do: nil + def unquote(:"find_#{item}")(unquote(identifier), unquote(opts)) do + result = unquote(block) + + case result do + {:ok, _, _, _} -> + result + + nil -> + nil + + false -> + nil + + [] -> + nil + end + end + + end + end +end diff --git a/lib/story/model/step/steppable.ex b/lib/story/model/step/steppable.ex index 7cccccc1..20ee3856 100644 --- a/lib/story/model/step/steppable.ex +++ b/lib/story/model/step/steppable.ex @@ -65,9 +65,24 @@ defprotocol Helix.Story.Model.Steppable do email. If `:complete` is given, Helix will call the `complete/1` function. The `do` block means an arbitrary command will be executed. - ## Setting up a new step with `setup/2` + ## Idempotent context creation with `setup/1` - The `setup/2` function is called when the previous step was completed, and + `setup/1` is responsible for generating the entire step environment in an + idempotent fashion. This is important because, in the case of a step restart, + fresh data *may* need to be regenerated. + + The list of events should include all events originated by the `setup/1` + method. So, for instance, if during the `setup/1` function we generate a file + and a log, both corresponding events should be passed upstream, so they can be + emitted by the StoryHandler. + + (Note that whenever someone requests a step restart, they are responsible for + cleaning up any stale/corrupt/invalid data. `setup/1` only creates data. See + the "Restarting a step" section for more information). + + ## Setting up a new step with `start/1` + + The `start/1` function is called when the previous step was completed, and this step will be marked as the player's current step. It receives both the current and the previous step struct (in most cases you can ignore the previous step). @@ -76,14 +91,9 @@ defprotocol Helix.Story.Model.Steppable do one of `:ok` | `:error` (where `:error` means something really bad happened internally). - It's on the `setup/2` function that a step should generate and prepare its - environment, as well as send its first email to the user (applicable in most - cases). - - Note that the list of events should include all events originated by the setup - method. So, for instance, if during the `setup/2` function we generate a file - and a log, both corresponding events should be passed upstream, so they can be - emitted by the StoryHandler. + `start/1` will use the `setup/1` method to generate and prepare the step data + and environment. Once this is done, it will also send the email to the user + (applicable in most cases). ## Reacting to arbitrary events with `handle_event` @@ -97,14 +107,16 @@ defprotocol Helix.Story.Model.Steppable do :noop message, meaning that that event should be ignored. Pay attention to the return types of `handle_event`! The return signature is - `{action, new_step [events]}`, where action may be one of: + `{action, new_step, [events]}`, where action may be one of: - :noop - Do nothing after `handle_event` is called. - :complete - Mark the step as completed by calling `complete/1` - - :fail - Mark the step as failed by calling `fail/1` + - {:restart, reason, checkpoint} - Mark the step as restarted by calling + `restart/3`. `reason` is used to notify the player why the step got + restarted, and `checkpoint` points to the last email that we'll rollback to. The `new_step` will be passed to the next functions if applicable (complete - or fail). Any events on the list of events will always be emitted, even if + or restart). Any events on the list of events will always be emitted, even if `:noop` is the returned action. ## Completing a step with `complete` @@ -125,6 +137,18 @@ defprotocol Helix.Story.Model.Steppable do In order to do so, simply use the `next_step` macro, passing the module name as argument, like `next_step Helix.Story.Mission.MissionName.StepName` + ## Restarting a step with `restart/3` + + A step is restarted through `restart/3`, which basically will regenerate the + step context (through `setup/1`) and return the updated step. Finally, + `StoryHandler` will rollback to the given checkpoint ("last good email") and + will notify the client through the `StepRestartedEvent`. + + Note that once `restart/3` is called, any incorrect/invalid data should have + been purged from the database. That's because `setup/1` will only recreate + data, not fix it. So it's very important that whenever a `:restart` action is + used on a callback, the invalid data (if any) is removed. + ## Example A working example can be seen at `lib/story/mission/tutorial/steps.ex` @@ -133,18 +157,8 @@ defprotocol Helix.Story.Model.Steppable do alias Helix.Event alias Helix.Story.Model.Step - @typedoc """ - The `actions` type lists all possible actions returned by `handle_event/3`, - which will be interpreted by the StepFlow implemented at StoryEventHandler. - """ - @type actions :: - :noop - | :complete - | :fail - @typep generic_step :: Step.t - - @spec setup(cur_step :: generic_step, prev_step :: generic_step | nil) :: - {:ok | :error, generic_step, [Event.t]} + @spec start(Step.t) :: + {:ok | :error, Step.t, [Event.t]} @doc """ Function called when the previous step was completed. It has the purpose of setting up the new step, preparing its environment and generating any objects @@ -158,10 +172,28 @@ defprotocol Helix.Story.Model.Steppable do environment generation. It should be logged and debug thoroughly, since no errors should happen during this step. """ - def setup(step, previous_step) + def start(step) + + @spec setup(Step.t) :: + {Step.meta, related :: map, [Event.t]} + | nil + @doc """ + Generates the required data for the step. + + It is idempotent, meaning it may be ran multiple times without prejudice for + valid, already-existing data. The reason for idempotency is because `setup/1` + is called both on `start/1` and `restart/3`. + + `setup/1` has a tooling of its own, the `setup_once` macro, which under the + hood relies on `StoryQuery.Setup`. Take a look at them. + + May return `nil` when it's not supposed to be called (i.e. steps generates no + data). + """ + def setup(step) - @spec handle_event(generic_step, Event.t, Step.meta) :: - {actions, generic_step, [Event.t]} + @spec handle_event(Step.t, Event.t, Step.meta) :: + {Step.callback_action, Step.t, [Event.t]} @doc """ Generic filtering of events. Any event will be pattern-matched against the implementations of `handle_event` of the given Step. @@ -170,38 +202,38 @@ defprotocol Helix.Story.Model.Steppable do If the returned action is `:noop`, the StepHandler will stop the flow. If `:complete` is returned, then `complete/1` will be called. On the other hand, - if `:fail` is returned, `fail/1` will be called. + if `{:restart, reason, checkpoint}` is returned, `restart/3` will be called. - Note that if a step is fallible, it must implement `fail/1` + Note that if a step is "restartable", it must explicitly implement `restart/3` """ def handle_event(step, event, meta) - @spec complete(generic_step) :: - {:ok | :error, generic_step, [Event.t]} + @spec complete(Step.t) :: + {:ok | :error, Step.t, [Event.t]} @doc """ Method called when the Step has been marked for completion. This can only happen when a specific event was matched and the returned action was `:complete`. - Similar to `setup`, `:error` should only be returned in ugly cases, in which + Similar to `start/1`, `:error` should only be returned in ugly cases, in which the error reason should be thoroughly logged and debugged. """ def complete(step) - @spec fail(generic_step) :: - {:ok | :error, generic_step, [Event.t]} + @spec restart(Step.t, reason :: atom, checkpoint :: Step.email_id) :: + {:ok | :error, Step.t, Step.email_meta, [Event.t]} @doc """ - Method used when the step fails. Note that most steps are not fallible, and - as such implementing this function is not necessary. + Method used when the step is restarted. Note that most steps are not + restartable, and as such implementing this function is not always necessary. - However, if you want this step to be fallible, it must implement `fail/1`. + However, if you want it to be restartable, it must be explicitly implemented. - `fail/1` is only called when a specific `handle_event` returned `:fail` as - action. + `restart/3` is only called when a specific `handle_event` returned + `{:restart, reason, checkpoint}` as the requested action. """ - def fail(step) + def restart(step, reason, checkpoint) - @spec next_step(generic_step) :: + @spec next_step(Step.t) :: Step.step_name @doc """ Points to the next step. @@ -224,7 +256,7 @@ defprotocol Helix.Story.Model.Steppable do """ def get_contact(step) - @spec format_meta(generic_step) :: + @spec format_meta(Step.t) :: Step.meta @doc """ Converts back the step's metadata to Helix internal data structure. Since the @@ -235,7 +267,7 @@ defprotocol Helix.Story.Model.Steppable do """ def format_meta(step) - @spec get_replies(generic_step, Step.email_id) :: + @spec get_replies(Step.t, Step.email_id) :: [Step.reply_id] @doc """ Returns all possible unlocked replies of an email. diff --git a/lib/story/model/story/email.ex b/lib/story/model/story/email.ex index 2f487360..14c1c28f 100644 --- a/lib/story/model/story/email.ex +++ b/lib/story/model/story/email.ex @@ -36,7 +36,7 @@ defmodule Helix.Story.Model.Story.Email do timestamp: DateTime.t, id: Step.email_id, sender: sender, - meta: Step.meta + meta: Step.email_meta } @type creation_params :: %{ @@ -46,7 +46,7 @@ defmodule Helix.Story.Model.Story.Email do @type email_creation_params :: %{ id: Step.email_id, - meta: Step.meta, + meta: Step.email_meta, sender: sender } @@ -87,6 +87,20 @@ defmodule Helix.Story.Model.Story.Email do } end + @spec rollback_email(t, Step.email_id, Step.email_meta) :: + changeset + @doc """ + Rollbacks the history of emails to the specified checkpoint (`email_id`) + """ + def rollback_email(entry, email_id, meta) do + email = create_email(email_id, meta, :contact) + + entry + |> change() + |> do_rollback_email(email) + |> generic_validations() + end + @spec format(t) :: t @doc """ @@ -115,6 +129,28 @@ defmodule Helix.Story.Model.Story.Email do } end + @spec do_rollback_email(changeset, email) :: + changeset + defp do_rollback_email(changeset, email) do + new_emails = + changeset + |> get_field(:emails) + |> Enum.reverse() + + # Remove all emails sent after `email_id` + |> Enum.drop_while(&(&1.id != email.id)) + + # Also remove `email_id`... + |> List.delete_at(0) + + # Which will be replaced by the new `email` + |> List.insert_at(0, email) + |> Enum.reverse() + + changeset + |> put_change(:emails, new_emails) + end + @spec generic_validations(changeset) :: changeset defp generic_validations(changeset) do diff --git a/lib/story/model/story/step.ex b/lib/story/model/story/step.ex index 7c16f68b..2013711f 100644 --- a/lib/story/model/story/step.ex +++ b/lib/story/model/story/step.ex @@ -85,7 +85,7 @@ defmodule Helix.Story.Model.Story.Step do """ def replace_meta(entry, meta) when is_map(meta) do entry - |> Changeset.change() + |> change() |> put_change(:meta, meta) end @@ -98,7 +98,7 @@ defmodule Helix.Story.Model.Story.Step do """ def unlock_reply(entry, reply_id) do entry - |> Changeset.change() + |> change() |> do_unlock(reply_id) end @@ -114,7 +114,7 @@ defmodule Helix.Story.Model.Story.Step do """ def lock_reply(entry, reply_id) do entry - |> Changeset.change() + |> change() |> do_lock(reply_id) end @@ -126,11 +126,24 @@ defmodule Helix.Story.Model.Story.Step do """ def append_email(entry, email_id, allowed_replies) do entry - |> Changeset.change() + |> change() |> do_append(email_id) |> put_change(:allowed_replies, allowed_replies) end + @spec rollback_email(t, Step.email_id, [Step.email_id]) :: + changeset + @doc """ + Rollbacks the entry to the given `email_id`. All emails sent after `email_id` + will be removed. The replies list will also be updated. + """ + def rollback_email(entry, email_id, allowed_replies) do + entry + |> change() + |> do_rollback(email_id) + |> put_change(:allowed_replies, allowed_replies) + end + @spec get_current_email(t) :: last_email :: Step.email_id | nil @@ -164,7 +177,7 @@ defmodule Helix.Story.Model.Story.Step do @spec do_unlock(changeset, Step.reply_id) :: changeset defp do_unlock(changeset, reply_id) do - previously_unlocked = get_field(changeset, :allowed_replies, []) + previously_unlocked = get_field(changeset, :allowed_replies) new_replies = previously_unlocked @@ -178,7 +191,7 @@ defmodule Helix.Story.Model.Story.Step do @spec do_lock(changeset, Step.reply_id) :: changeset defp do_lock(changeset, reply_id) do - previously_unlocked = get_field(changeset, :allowed_replies, []) + previously_unlocked = get_field(changeset, :allowed_replies) new_replies = List.delete(previously_unlocked, reply_id) changeset @@ -188,12 +201,27 @@ defmodule Helix.Story.Model.Story.Step do @spec do_append(changeset, Step.email_id) :: changeset defp do_append(changeset, email_id) do - previously_sent = get_field(changeset, :emails_sent, []) + previously_sent = get_field(changeset, :emails_sent) changeset |> put_change(:emails_sent, previously_sent ++ [email_id]) end + @spec do_rollback(changeset, Step.email_id) :: + changeset + defp do_rollback(changeset, email_id) do + emails_sent = get_field(changeset, :emails_sent) + + new_sent = + emails_sent + |> Enum.reverse() + |> Enum.drop_while(&(&1 != email_id)) + |> Enum.reverse() + + changeset + |> put_change(:emails_sent, new_sent) + end + query do alias Helix.Entity.Model.Entity diff --git a/lib/story/query/story.ex b/lib/story/query/story.ex index 90fea90d..1ed9c321 100644 --- a/lib/story/query/story.ex +++ b/lib/story/query/story.ex @@ -28,6 +28,16 @@ defmodule Helix.Story.Query.Story do defdelegate get_steps(entity_id), to: StepInternal + @spec fetch_email(Entity.id, Step.contact) :: + Story.Email.t + | nil + @doc """ + Fetches all emails from a given contact. + """ + defdelegate fetch_email(entity_id, contact_id), + to: EmailInternal, + as: :fetch + @spec get_emails(Entity.id) :: [Story.Email.t] @doc """ diff --git a/lib/story/query/story/setup.ex b/lib/story/query/story/setup.ex new file mode 100644 index 00000000..f3932e95 --- /dev/null +++ b/lib/story/query/story/setup.ex @@ -0,0 +1,118 @@ +defmodule Helix.Story.Query.Story.Setup do + @moduledoc """ + `StoryQuery.Setup` is a helper tailored for idempotent Step Setups. It should + only be used within that context! + + It will check whether the item (:file, :server, :pftp_file, :char, ...) exists + *and* is valid within the reasonable step context. + + For instance, a file that is hidden will, by default, return `nil` even if it + does exist. + + If the Step wants a more flexible approach (e.g. hidden files are OK, but + encrypted ones aren't), the `opts` argument should be used for specificity. + + ### Return format + + In case of failure (object was not found OR object is not deemed valid + according to the given `opts`), it should always return `nil`. + + Otherwise, if the object was found, it must return the same format of Make* + files, which is: + + {:ok, `item`, `related`, `events`} where: + + - `item` is the object / struct that is being requested + - `related` is a map of values related to `item`. Usually it's empty. + - `events` is a list of events that should be emitted. Almost always it's an + empty list. + """ + + import Helix.Story.Model.Step.Macros.Setup + + alias Helix.Entity.Model.Entity + alias Helix.Entity.Query.Entity, as: EntityQuery + alias Helix.Server.Model.Server + alias Helix.Server.Query.Server, as: ServerQuery + alias Helix.Software.Model.File + alias Helix.Software.Model.PublicFTP + alias Helix.Software.Query.File, as: FileQuery + alias Helix.Software.Query.PublicFTP, as: PublicFTPQuery + alias Helix.Story.Model.Step + alias Helix.Story.Query.Context, as: ContextQuery + + @typep opts :: list + + @spec find_char({Entity.id, Step.contact_id}, opts) :: + {:ok, Server.t, %{entity: Entity.t}, list} + | nil + @doc """ + Checks whether the `char` exists. + + - A Context.t must exist for the given {`entity_id`, `contact_id`} + - The contact's server must exist + - The contact's entity must exist + """ + find :char, {entity_id = %Entity.ID{}, contact_id} do + with \ + context = %{} <- ContextQuery.get(entity_id, contact_id), + server = %{} <- ServerQuery.fetch(context.server_id), + entity = %{} <- EntityQuery.fetch(context.entity_id) + do + {:ok, server, %{entity: entity}, []} + end + end + + @spec find_file(File.idt, opts) :: + {:ok, File.t, %{}, list} + | nil + @doc """ + Checks whether the `file` exists + + - File must exist + - File must not be hidden (NOTYET) + - File must not be encrypted (NOTYET) + """ + find :file, %File{}, get: :file_id + find :file, file_id = %File.ID{} do + with file = %{} <- FileQuery.fetch(file_id) do + {:ok, file, %{}, []} + end + end + + @spec find_pftp_server(Server.idt, opts) :: + {:ok, PublicFTP.t, %{}, list} + | nil + @doc """ + Checks whether the PublicFTP server exists. + + - PFTPServer must exist for the given server + - PFTPServer must be enabled + """ + find :pftp_server, %Server{}, get: :server_id + find :pftp_server, server_id = %Server.ID{} do + with \ + pftp = %{} <- PublicFTPQuery.fetch_server(server_id), + true <- PublicFTP.is_active?(pftp) + do + {:ok, pftp, %{}, []} + end + end + + @spec find_pftp_file(File.idt, opts) :: + {:ok, PublicFTP.File.t, %{}, list} + | nil + @doc """ + Checks whether the File is added to the PublicFTP Server. + + - PublicFTP Server must exist (indirect check) + - PublicFTP Server must be enabled (indirect check) + - File must be added to the PublicFTP Server + """ + find :pftp_file, %File{}, get: :file_id + find :pftp_file, file_id = %File.ID{} do + with pftp_file = %{} <- PublicFTPQuery.fetch_file(file_id) do + {:ok, pftp_file, %{}, []} + end + end +end diff --git a/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs b/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs new file mode 100644 index 00000000..bd8443ba --- /dev/null +++ b/priv/repo/software/migrations/20180210115107_fix_cascade_relationship_on_pftp_file.exs @@ -0,0 +1,20 @@ +defmodule Helix.Software.Repo.Migrations.FixCascadeRelationshipOnPFTPFile do + use Ecto.Migration + + def change do + # Remove old FK + execute """ + ALTER TABLE pftp_files + DROP CONSTRAINT pftp_files_file_id_fkey + """ + + # Add new FK with ON DELETE CASCADE (as opposed to SET NULL) + execute """ + ALTER TABLE pftp_files + ADD CONSTRAINT pftp_files_file_id_fkey + FOREIGN KEY (file_id) + REFERENCES files(file_id) + ON DELETE CASCADE + """ + end +end diff --git a/priv/repo/story/migrations/20180201171652_story_contact.exs b/priv/repo/story/migrations/20180201171652_story_contact.exs index e4f337ec..7e3a172f 100644 --- a/priv/repo/story/migrations/20180201171652_story_contact.exs +++ b/priv/repo/story/migrations/20180201171652_story_contact.exs @@ -23,8 +23,7 @@ defmodule Helix.Story.Repo.Migrations.StoryContact do add :emails, {:array, :jsonb}, null: false, default: [] end - # Apparently Ecto does not work well with composite FKs - # https://elixirforum.com/t/does-ecto-supports-composite-foreign-keys/2466 + # FK below was removed. See `RemoveStoryEmailsFK` for context execute """ ALTER TABLE story_emails ADD CONSTRAINT story_emails_fkey diff --git a/priv/repo/story/migrations/20180211060220_remove_story_emails_fk.exs b/priv/repo/story/migrations/20180211060220_remove_story_emails_fk.exs new file mode 100644 index 00000000..a494b0c5 --- /dev/null +++ b/priv/repo/story/migrations/20180211060220_remove_story_emails_fk.exs @@ -0,0 +1,12 @@ +defmodule Helix.Story.Repo.Migrations.RemoveStoryEmailsFK do + use Ecto.Migration + + def change do + # Removing the `story_emails` FK because emails are mostly historical, they + # still exist even if all steps from a specific contact have been completed + execute """ + ALTER TABLE story_emails + DROP CONSTRAINT story_emails_fkey + """ + end +end diff --git a/test/account/websocket/channel/account/topics/email_reply_test.exs b/test/account/websocket/channel/account/topics/email_reply_test.exs index ee77a68c..14a9222f 100644 --- a/test/account/websocket/channel/account/topics/email_reply_test.exs +++ b/test/account/websocket/channel/account/topics/email_reply_test.exs @@ -94,7 +94,7 @@ defmodule Helix.Account.Websocket.Channel.Account.Topics.EmailReplyTest do {socket, %{entity_id: entity_id}} = ChannelSetup.join_account() # Remove any existing step - StoryHelper.remove_existing_step(entity_id) + StoryHelper.remove_existing_steps(entity_id) params = %{ diff --git a/test/features/storyline/flow_test.exs b/test/features/storyline/quests/tutorial_test.exs similarity index 68% rename from test/features/storyline/flow_test.exs rename to test/features/storyline/quests/tutorial_test.exs index f84b351b..48fa038c 100644 --- a/test/features/storyline/flow_test.exs +++ b/test/features/storyline/quests/tutorial_test.exs @@ -1,4 +1,4 @@ -defmodule Helix.Test.Features.Storyline.Flow do +defmodule Helix.Test.Features.Storyline.Quests.Tutorial do use Helix.Test.Case.Integration @@ -14,6 +14,7 @@ defmodule Helix.Test.Features.Storyline.Flow do alias Helix.Test.Entity.Helper, as: EntityHelper alias Helix.Test.Process.TOPHelper alias Helix.Test.Server.Helper, as: ServerHelper + alias Helix.Test.Story.Vars, as: StoryVars @moduletag :feature @@ -30,15 +31,33 @@ defmodule Helix.Test.Features.Storyline.Flow do account_id: account.account_id, socket: server_socket ) + # Inherit storyline variables + s = StoryVars.vars() + # Player is on mission assert [%{object: cur_step}] = StoryQuery.get_steps(entity_id) assert cur_step.name == Step.first_step_name() - # We'll now complete the first step by replying to the email + # We'll now progress on the first step by replying to the email params = %{ - "reply_id" => "back_thanks", - "contact_id" => to_string(cur_step.contact) + "reply_id" => s.step.setup_pc.msg2, + "contact_id" => s.contact.friend + } + ref = push account_socket, "email.reply", params + assert_reply ref, :ok, _, timeout() + + # Contact just replied with the next message + [story_email_sent] = wait_events [:story_email_sent] + + assert_email \ + story_email_sent, s.step.setup_pc.msg3, s.step.setup_pc.msg4, cur_step + + # Now we'll reply back and finally proceed to the next step + params = + %{ + "reply_id" => s.step.setup_pc.msg4, + "contact_id" => s.contact.friend } ref = push account_socket, "email.reply", params assert_reply ref, :ok, _, timeout(:slow) @@ -46,7 +65,8 @@ defmodule Helix.Test.Features.Storyline.Flow do # Now we've proceeded to the next step. [story_step_proceeded] = wait_events [:story_step_proceeded] - assert_transition story_step_proceeded, "setup_pc", "download_cracker" + assert_transition \ + story_step_proceeded, s.step.setup_pc.name, s.step.setup_pc.next # Fetch setup data %{object: cur_step} = StoryQuery.fetch_step(entity_id, cur_step.contact) @@ -75,7 +95,7 @@ defmodule Helix.Test.Features.Storyline.Flow do [story_step_proceeded] = wait_events [:story_step_proceeded] assert_transition \ - story_step_proceeded, "download_cracker", "download_cracker" + story_step_proceeded, "download_cracker", s.step.setup_pc.name end end end diff --git a/test/features/storyline/restart_test.exs b/test/features/storyline/restart_test.exs new file mode 100644 index 00000000..e666e007 --- /dev/null +++ b/test/features/storyline/restart_test.exs @@ -0,0 +1,95 @@ +defmodule Helix.Test.Features.Storyline.Restart do + + use Helix.Test.Case.Integration + + import Helix.Test.Channel.Macros + + alias Helix.Software.Internal.File, as: FileInternal + alias Helix.Story.Query.Story, as: StoryQuery + + alias Helix.Test.Event.Helper, as: EventHelper + alias Helix.Test.Event.Setup, as: EventSetup + alias Helix.Test.Channel.Setup, as: ChannelSetup + alias Helix.Test.Entity.Helper, as: EntityHelper + alias Helix.Test.Story.Helper, as: StoryHelper + + @moduletag :feature + + describe "restart" do + test "flow" do + {server_socket, %{account: account}} = + ChannelSetup.join_storyline_server() + + entity = EntityHelper.fetch_entity_from_account(account) + entity_id = entity.entity_id + + # Subscribe for events + ChannelSetup.join_account( + account_id: account.account_id, socket: server_socket + ) + + # Player is on mission + [%{entry: story_step}] = StoryQuery.get_steps(entity_id) + + # Magically proceed to the next step (DownloadCracker) by replying to the + # first one (SetupPC) twice + StoryHelper.reply(story_step) + StoryHelper.reply(story_step) + + [%{entry: story_step, object: step}] = StoryQuery.get_steps(entity_id) + + # We are on DownloadCracker + assert step.name == :tutorial@download_cracker + + # We've the email received `download_cracker1` + assert story_step.emails_sent == ["download_cracker1"] + + # 5 emails: 2e + 2r from prev step + "download_cracker1" + story_email = StoryQuery.fetch_email(entity_id, step.contact) + assert length(story_email.emails) == 5 + [_, _, _, _, email] = story_email.emails + + assert email.meta["ip"] == step.meta.ip + + # Now we'll be nasty and delete the cracker... + step.meta.cracker_id + |> FileInternal.fetch() + |> FileInternal.delete() + + # And notify the user... + step.meta.cracker_id + |> EventSetup.Software.file_deleted(step.meta.server_id) + |> EventHelper.emit() + + [story_step_restarted] = wait_events [:story_step_restarted] + + # The client received the notification about the new step + assert story_step_restarted.data.reason == "file_deleted" + assert story_step_restarted.data.checkpoint == "download_cracker1" + assert story_step_restarted.data.step == to_string(step.name) + assert story_step_restarted.data.meta.ip == step.meta.ip + assert story_step_restarted.data.allowed_replies + + # By querying directly on the DB, the data has been updated as well + [%{object: new_step}] = StoryQuery.get_steps(entity_id) + + # `cracker_id` has changed (it was regenerated) + refute new_step.meta.cracker_id == step.meta.cracker_id + + # But the unchanged data is the same + assert new_step.meta.server_id == step.meta.server_id + assert new_step.meta.ip == step.meta.ip + + # Still 5 emails... + story_email = StoryQuery.fetch_email(entity_id, step.contact) + assert length(story_email.emails) == 5 + [_, _, _, _, new_email] = story_email.emails + + assert new_email.meta["ip"] == step.meta.ip + + # Yet, even though both `email` and `new_email` have the same meta and ID, + # they are not the same (different timestamps) + refute new_email == email + end + end +end diff --git a/test/story/action/story_test.exs b/test/story/action/story_test.exs index 34319e56..efb3df6d 100644 --- a/test/story/action/story_test.exs +++ b/test/story/action/story_test.exs @@ -3,6 +3,7 @@ defmodule Helix.Story.Action.StoryTest do use Helix.Test.Case.Integration alias Helix.Story.Action.Story, as: StoryAction + alias Helix.Story.Action.Flow.Story, as: StoryFlow alias Helix.Story.Model.Step alias Helix.Story.Model.Story alias Helix.Story.Query.Story, as: StoryQuery @@ -81,7 +82,6 @@ defmodule Helix.Story.Action.StoryTest do assert length(allowed_after) == length(allowed_before) - 1 refute Enum.member?(allowed_after, reply_id) - end test "invalid reply is not sent" do @@ -95,4 +95,84 @@ defmodule Helix.Story.Action.StoryTest do assert reason == {:reply, :not_found} end end + + describe "rollback_emails/3" do + test "returns to specified checkpoint (first element)" do + {_, %{entity_id: entity_id}} = + StorySetup.story_step(name: :fake_steps@test_msg_flow, meta: %{}) + + [%{object: step}] = StoryQuery.get_steps(entity_id) + + StoryFlow.send_reply(entity_id, step.contact, "reply_to_e1") + StoryFlow.send_reply(entity_id, step.contact, "reply_to_e2") + + # Story.Step has all three emails + [%{entry: story_step}] = StoryQuery.get_steps(entity_id) + assert story_step.emails_sent == ["e1", "e2", "e3"] + + # And there are 5 registered emails (3 from the contact + 2 replies) + [emails] = StoryQuery.get_emails(entity_id) + assert emails.contact_id == step.contact + assert length(emails.emails) == 5 + + # Let's rollback to `e1` + new_meta = %{"foo" => "bar"} + assert {:ok, story_step, story_email} = + StoryAction.rollback_emails(step, "e1", new_meta) + + # Story.Step only has `e1`. `e2` and `e3` argon + assert story_step.emails_sent == ["e1"] + + # There must be only one email (`e1`). Anything after that was removed + assert length(story_email.emails) == 1 + + # And the email that is left had its metadata updated. + [message] = story_email.emails + + assert message.id == "e1" + assert message.meta == new_meta + assert message.sender == :contact + end + + # Same test as above ("first element"), but now we'll use as checkpoint + # a message in the middle of the stack. Seems a small change but it covers + # many extra edge cases + test "returns to specified checkpoint (middle element)" do + {_, %{entity_id: entity_id}} = + StorySetup.story_step(name: :fake_steps@test_msg_flow, meta: %{}) + + [%{object: step}] = StoryQuery.get_steps(entity_id) + + StoryFlow.send_reply(entity_id, step.contact, "reply_to_e1") + StoryFlow.send_reply(entity_id, step.contact, "reply_to_e2") + + # Story.Step has all three emails + [%{entry: story_step}] = StoryQuery.get_steps(entity_id) + assert story_step.emails_sent == ["e1", "e2", "e3"] + + # And there are 5 registered emails (3 from the contact + 2 replies) + [emails] = StoryQuery.get_emails(entity_id) + assert emails.contact_id == step.contact + assert length(emails.emails) == 5 + + # Let's rollback to `e2` (which is in the middle of the stack!) + new_meta = %{"foo" => "bar"} + assert {:ok, story_step, story_email} = + StoryAction.rollback_emails(step, "e2", new_meta) + + # Story.Step has `e1` and `e2`. `e3` isgon + assert story_step.emails_sent == ["e1", "e2"] + + # There are 3 emails on the story. `e1`, `reply_to_e1` and `e2` + assert length(story_email.emails) == 3 + + # And the email that is left had its metadata updated. + [m1, m2, m3] = story_email.emails + + assert m1.id == "e1" + assert m2.id == "reply_to_e1" + assert m3.id == "e2" + assert m3.meta == new_meta + end + end end diff --git a/test/story/event/handler/story_test.exs b/test/story/event/handler/story_test.exs index c2b85629..00c8d02f 100644 --- a/test/story/event/handler/story_test.exs +++ b/test/story/event/handler/story_test.exs @@ -4,11 +4,13 @@ defmodule Helix.Story.Event.Handler.StoryTest do import ExUnit.CaptureLog + alias Helix.Software.Internal.File, as: FileInternal alias Helix.Story.Model.Step alias Helix.Story.Query.Story, as: StoryQuery alias Helix.Test.Event.Helper, as: EventHelper alias Helix.Test.Event.Setup, as: EventSetup + alias Helix.Test.Story.Helper, as: StoryHelper alias Helix.Test.Story.Setup, as: StorySetup describe "handling of ReplySent events" do @@ -49,4 +51,63 @@ defmodule Helix.Story.Event.Handler.StoryTest do assert new_step.name == Step.get_next_step(step) end end + + describe "handling of restart events" do + test "restarts to the specified checkpoint" do + {story_step, %{step: step}} = StorySetup.story_step( + name: :tutorial@download_cracker, meta: %{}, ready: true + ) + + # Just to make sure the generated step went through `Steppable.start/1` + assert story_step.meta.server_id + assert story_step.meta.ip + assert story_step.meta.cracker_id + + # Advance a few messages so we can check that it rolled back to checkpoint + StoryHelper.reply(story_step) + + # There are 3 registered emails (2 from contact and 1 reply) + story_email = StoryQuery.fetch_email(step.entity_id, step.contact) + assert length(story_email.emails) == 3 + + %{entry: story_step} = StoryQuery.fetch_step(step.entity_id, step.contact) + + # Remove the file + story_step.meta.cracker_id + |> FileInternal.fetch() + |> FileInternal.delete() + + # Fake a FileDeletedEvent + story_step.meta.cracker_id + |> EventSetup.Software.file_deleted(story_step.meta.server_id) + |> EventHelper.emit() + + %{entry: new_entry, object: new_step} = + StoryQuery.fetch_step(step.entity_id, step.contact) + + # Story meta has been updated! + refute new_entry.meta.cracker_id == story_step.meta.cracker_id + + # Other stuff hasn't changed + assert new_entry.meta.server_id == story_step.meta.server_id + assert new_entry.meta.ip == story_step.meta.ip + + # Object (Step.t) meta is also correct + assert new_entry.meta == new_step.meta + assert story_step.meta == step.meta + refute new_entry.meta == story_step.meta + + # The `allowed_replies` is different because the messages were rolled back + refute new_entry.allowed_replies == story_step.allowed_replies + + story_email = StoryQuery.fetch_email(step.entity_id, step.contact) + + # There's only one email: the one we've rolled back to. + assert [email] = story_email.emails + assert email.id == "download_cracker1" + + # Email meta got updated too + assert email.meta["ip"] == new_entry.meta.ip + end + end end diff --git a/test/story/model/macro_test.exs b/test/story/model/macro_test.exs index ba11d2de..27620a15 100644 --- a/test/story/model/macro_test.exs +++ b/test/story/model/macro_test.exs @@ -4,6 +4,7 @@ defmodule Helix.Story.Model.MacroTest do import ExUnit.CaptureLog + alias Helix.Software.Internal.File, as: FileInternal alias Helix.Story.Event.Email.Sent, as: StoryEmailSentEvent alias Helix.Story.Model.Steppable @@ -51,4 +52,45 @@ defmodule Helix.Story.Model.MacroTest do Steppable.handle_event(step, invalid_reply_event, %{}) end end + + describe "setup_once" do + test "handles hit and misses" do + # NOTE: The step used here is simply for dummy data; that's why this test + # will probably be repeated on the same step's tests + + {step, _} = StorySetup.step( + name: :tutorial@download_cracker, + meta: %{}, + ready: true + ) + + # Generate for the first time (100% misses) + assert {meta, _, _events} = Steppable.setup(step) + + assert meta.cracker_id + assert meta.server_id + assert meta.ip + + # Try again, now redoing everything (for idempotency, should be 100% hits) + step = %{step| meta: meta} + assert {meta2, _, _events} = Steppable.setup(step) + assert meta2 == meta + + # Now we'll nuke the `cracker_id`. + meta.cracker_id + |> FileInternal.fetch() + |> FileInternal.delete() + + # As a result, a new `cracker_id` should be generated, but everything else + # should be the same + assert {meta3, _, _events} = Steppable.setup(step) + + # New cracker was generated + refute meta3.cracker_id == meta.cracker_id + + # But the other stuff is the same + assert meta3.server_id == meta.server_id + assert meta3.ip == meta.ip + end + end end diff --git a/test/story/quests/tutorial_test.exs b/test/story/quests/tutorial_test.exs new file mode 100644 index 00000000..e9677b23 --- /dev/null +++ b/test/story/quests/tutorial_test.exs @@ -0,0 +1,33 @@ +defmodule Helix.Story.Quests.Tutorial.DownloadCrackerTest do + + use Helix.Test.Case.Integration + + alias Helix.Story.Model.Steppable + + alias Helix.Test.Story.Setup, as: StorySetup + + describe "setup/1" do + test "creates the context/environment; idempotent" do + {step, _} = StorySetup.step( + name: :tutorial@download_cracker, meta: %{}, ready: true + ) + + assert {meta, _, _events} = Steppable.setup(step) + + assert meta.server_id + assert meta.cracker_id + assert meta.ip + + # Ensure idempotency + step = %{step| meta: meta} + assert {meta2, _, _events} = Steppable.setup(step) + assert meta2 == meta + end + end + + describe "callbacks" do + # Tested on `StoryHandlerTest` + # test "on_file_deleted" do + # end + end +end diff --git a/test/support/channel/macros.ex b/test/support/channel/macros.ex index c6c3c2f8..37ee490d 100644 --- a/test/support/channel/macros.ex +++ b/test/support/channel/macros.ex @@ -56,9 +56,9 @@ defmodule Helix.Test.Channel.Macros do @doc """ Debugger/helper that lists all events in the mailbox. """ - defmacro list_events do + defmacro list_events(timeout \\ quote(do: 50)) do quote do - unquote(wait_all()) + unquote(wait_all(timeout)) end end end diff --git a/test/support/event/setup/software.ex b/test/support/event/setup/software.ex index 42d6949b..9c510729 100644 --- a/test/support/event/setup/software.ex +++ b/test/support/event/setup/software.ex @@ -7,7 +7,9 @@ defmodule Helix.Test.Event.Setup.Software do alias Helix.Server.Model.Server alias Helix.Software.Internal.File, as: FileInternal alias Helix.Software.Internal.Storage, as: StorageInternal + alias Helix.Software.Model.File + alias Helix.Software.Event.File.Deleted, as: FileDeletedEvent alias Helix.Software.Event.File.Downloaded, as: FileDownloadedEvent alias Helix.Software.Event.File.DownloadFailed, as: FileDownloadFailedEvent alias Helix.Software.Event.File.Install.Processed, @@ -58,6 +60,12 @@ defmodule Helix.Test.Event.Setup.Software do } end + @doc """ + Generates a FileDeletedEvent + """ + def file_deleted(file_id = %File.ID{}, server_id = %Server.ID{}), + do: FileDeletedEvent.new(file_id, server_id) + @doc """ Generates a FileDownloaded event with real data. """ diff --git a/test/support/story/fake_steps.ex b/test/support/story/fake_steps.ex index e4f5124e..37e92ea6 100644 --- a/test/support/story/fake_steps.ex +++ b/test/support/story/fake_steps.ex @@ -44,7 +44,9 @@ defmodule Helix.Story.Mission.FakeSteps do alias Helix.Entity.Model.Entity - def setup(step, _), + empty_setup() + + def start(step), do: {:ok, step, []} def complete(step), @@ -68,15 +70,23 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step), do: {:ok, step, []} + def complete(step), do: {:ok, step, []} + next_step __MODULE__ end step TestOne do - def setup(step, _), + + empty_setup() + + def start(step), do: {:ok, step, []} def complete(step), @@ -89,7 +99,10 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestTwo do - def setup(step, _), + + empty_setup() + + def start(step), do: {:ok, step, []} def complete(step), @@ -102,7 +115,10 @@ defmodule Helix.Story.Mission.FakeSteps do end step TestCounter do - def setup(step, _), + + empty_setup() + + def start(step), do: {:ok, step, []} def complete(step), @@ -138,7 +154,9 @@ defmodule Helix.Story.Mission.FakeSteps do on_reply "reply_to_e3", :complete - def setup(step, _) do + empty_setup() + + def start(step) do send_email step, "e1" {:ok, step, []} end @@ -148,6 +166,39 @@ defmodule Helix.Story.Mission.FakeSteps do next_step Helix.Story.Mission.FakeSteps.TestSimple end + + step TestMsgFlow do + + email "e1", + reply: ["reply_to_e1"] + + on_reply "reply_to_e1", + send: "e2" + + email "e2", + reply: ["reply_to_e2"] + + on_reply "reply_to_e2", + send: "e3" + + email "e3", + reply: ["reply_to_e3"] + + on_reply "reply_to_e3", + :complete + + empty_setup() + + def start(step) do + send_email step, "e1" + {:ok, step, []} + end + + def complete(step), + do: {:ok, step, []} + + next_step __MODULE__ + end end defmodule Helix.Story.Mission.FakeContactOne do @@ -157,10 +208,15 @@ defmodule Helix.Story.Mission.FakeContactOne do contact :contact_one step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step), do: {:ok, step, []} + def complete(step), do: {:ok, step, []} + next_step __MODULE__ end end @@ -172,10 +228,15 @@ defmodule Helix.Story.Mission.FakeContactTwo do contact :contact_two step TestSimple do - def setup(step, _), + + empty_setup() + + def start(step), + do: {:ok, step, []} + + def complete(step), do: {:ok, step, []} - def complete(step), - do: {:ok, step, []} - next_step __MODULE__ + + next_step __MODULE__ end end diff --git a/test/support/story/helper.ex b/test/support/story/helper.ex index 5c156038..7ba537ec 100644 --- a/test/support/story/helper.ex +++ b/test/support/story/helper.ex @@ -1,10 +1,16 @@ defmodule Helix.Test.Story.Helper do + alias Helix.Event + alias Helix.Story.Action.Story, as: StoryAction + alias Helix.Story.Action.Flow.Story, as: StoryFlow alias Helix.Story.Internal.Step, as: StepInternal + alias Helix.Story.Model.Step + alias Helix.Story.Model.Steppable alias Helix.Story.Model.Story + alias Helix.Story.Query.Story, as: StoryQuery alias Helix.Story.Repo, as: StoryRepo - def remove_existing_step(entity_id) do + def remove_existing_steps(entity_id) do entity_id |> StepInternal.get_steps() |> Enum.each(&(StoryRepo.delete(&1.entry))) @@ -22,6 +28,55 @@ defmodule Helix.Test.Story.Helper do |> Enum.random() end + @doc """ + Automagically replies to a step. + + It randomly selects the `reply_id` from the `allowed_replies` on the given + `story_step`. Prone to error in some cases so beware. + """ + def reply(story_step) do + # Ensure an up-to-date struct + %{entry: story_step} = + StoryQuery.fetch_step(story_step.entity_id, story_step.contact_id) + + reply_id = get_allowed_reply(story_step) + + StoryFlow.send_reply(story_step.entity_id, story_step.contact_id, reply_id) + end + + @doc """ + Automagically proceeds to the next step. It detects the next step after `step` + and will proceed to that. IT WILL NOT PROCEED TO `step`, BUT TO THE NEXT ONE! + """ + def proceed_step(step) do + next_step = + step + |> Steppable.next_step() + |> Step.fetch(step.entity_id, %{}, step.manager) + + remove_existing_steps(step.entity_id) + + start_step(next_step) + end + + @doc """ + Helper to simulate a step being started. It's very similar to `update_next/2` + on StoryHandler. + """ + def start_step(step) do + with \ + {:ok, _} <- StoryAction.proceed_step(step), + + {:ok, next_step, events} <- Steppable.start(step), + Event.emit(events), + + # Update step meta + {:ok, _} <- StoryAction.update_step_meta(next_step) + do + {:ok, next_step} + end + end + @doc """ Generates random contact id """ diff --git a/test/support/story/macros.ex b/test/support/story/macros.ex index 387290b1..161153ca 100644 --- a/test/support/story/macros.ex +++ b/test/support/story/macros.ex @@ -1,15 +1,42 @@ defmodule Helix.Test.Story.Macros do + alias HELL.Utils + @doc """ Asserts that the story has transitioned from `expected_from` to `expected_to` """ defmacro assert_transition(event, expected_from, expected_to) do quote do + event_from = unquote(event).data.previous_step event_to = unquote(event).data.next_step assert String.contains?(event_from, unquote(expected_from)) assert String.contains?(event_to, unquote(expected_to)) + + end + end + + @doc """ + Asserts that the received email is the one expected, including the allowed + replies and the contact/step are the same as before. + """ + defmacro assert_email(event, expected_email, replies, step) do + quote do + + event_data = unquote(event).data + replies = Utils.ensure_list(unquote(replies)) + + assert unquote(event).event == "story_email_sent" + assert event_data.email_id == unquote(expected_email) + + Enum.each(replies, fn reply -> + assert Enum.member?(event_data.replies, reply) + end) + + assert to_string(unquote(step).contact) == event_data.contact_id + assert to_string(unquote(step).name) == event_data.step + end end end diff --git a/test/support/story/setup.ex b/test/support/story/setup.ex index d8db81bb..5b5c7dd2 100644 --- a/test/support/story/setup.ex +++ b/test/support/story/setup.ex @@ -1,17 +1,16 @@ defmodule Helix.Test.Story.Setup do alias Helix.Entity.Model.Entity - alias Helix.Story.Action.Story, as: StoryAction alias Helix.Story.Internal.Email, as: EmailInternal alias Helix.Story.Internal.Step, as: StepInternal alias Helix.Story.Model.Step - alias Helix.Story.Model.Steppable alias Helix.Story.Model.Story alias Helix.Story.Query.Manager, as: ManagerQuery alias Helix.Story.Query.Story, as: StoryQuery alias Helix.Story.Repo, as: StoryRepo alias HELL.TestHelper.Random + alias Helix.Test.Story.Helper, as: StoryHelper alias Helix.Test.Story.StepHelper, as: StoryStepHelper alias __MODULE__, as: StorySetup @@ -20,6 +19,8 @@ defmodule Helix.Test.Story.Setup do - name - Must be used with `meta`. Defaults to random step from FakeStep - meta - Must be used with `name`. Defaults to random step from FakeStep - manager - Set Step manager. Defaults to generating a random Story.Manager + - ready - Whether to generate a fully ready step (valid Context and Manager). + Defaults to false. Related: Entity.id """ @@ -35,7 +36,21 @@ defmodule Helix.Test.Story.Setup do end entity_id = Keyword.get(opts, :entity_id, Entity.ID.generate()) - manager = Keyword.get(opts, :manager, get_or_create_manager(entity_id)) + + manager = + if opts[:ready] do + StorySetup.Manager.manager!(real_network: true, entity_id: entity_id) + else + Keyword.get(opts, :manager, get_or_create_manager(entity_id)) + end + + # If the user requested a "ready" step, we'll make sure to create a valid + # context for the entity. "ready" steps also have the manager ensured, + # as well as valid underlying data (except for the entity_id, which is + # generated randomly, but may be overridden) + if opts[:ready] do + StorySetup.Context.context(entity_id: entity_id) + end step = Step.fetch(name, entity_id, meta, manager) @@ -50,18 +65,15 @@ defmodule Helix.Test.Story.Setup do See doc on `fake_story_step/1` """ def story_step(opts \\ []) do - {_, related = %{step: step}} = fake_story_step(opts) - # Save the step on DB and run its `setup` - {:ok, _} = StoryAction.proceed_step(step) - {:ok, _, _} = Steppable.setup(step, %{}) - - manager = get_or_create_manager(step.entity_id) - # Update step with the newly created manager + manager = get_or_create_manager(step.entity_id) step = %{step| manager: manager} + # Save the step on DB and `start/2` it + {:ok, step} = StoryHelper.start_step(step) + related = related |> put_in([:manager], manager) @@ -94,6 +106,8 @@ defmodule Helix.Test.Story.Setup do - emails_sent - Defaults to none - allowed_replies - Defaults to none - manager - Set step manager. Defaults to nil + - ready - Whether to generate a ready story_step (valid Context and Manager). + Defaults to false. Related: Entity.id, (Step.t if given step name is valid) """ @@ -110,9 +124,23 @@ defmodule Helix.Test.Story.Setup do entity_id = Keyword.get(opts, :entity_id, Entity.ID.generate()) + manager = + if opts[:ready] do + StorySetup.Manager.manager!(real_network: true, entity_id: entity_id) + else + Keyword.get(opts, :manager, nil) + end + + # If the user requested a "ready" step, we'll make sure to create a valid + # context for the entity. "ready" steps also have the manager ensured, + # as well as valid underlying data (except for the entity_id, which is + # generated randomly, but may be overridden) + if opts[:ready] do + StorySetup.Context.context(entity_id: entity_id) + end + emails_sent = Keyword.get(opts, :emails_sent, []) allowed_replies = Keyword.get(opts, :allowed_replies, []) - manager = Keyword.get(opts, :manager, nil) step = if opts[:contact_id] do diff --git a/test/support/story/setup/manager.ex b/test/support/story/setup/manager.ex index 14fa8b5c..902c413d 100644 --- a/test/support/story/setup/manager.ex +++ b/test/support/story/setup/manager.ex @@ -6,6 +6,7 @@ defmodule Helix.Test.Story.Setup.Manager do alias Helix.Test.Entity.Setup, as: EntitySetup alias Helix.Test.Network.Helper, as: NetworkHelper + alias Helix.Test.Network.Setup, as: NetworkSetup alias Helix.Test.Server.Setup, as: ServerSetup @doc """ @@ -17,15 +18,33 @@ defmodule Helix.Test.Story.Setup.Manager do {inserted, related} end + def manager!(opts \\ []) do + {manager, _} = manager(opts) + manager + end + @doc """ - entity_id: Set entity whose Manager belongs to. Defaults to fake entity - server_id: Set server ID. Defaults to fake server - network_id: Set network ID. Defaults to fake network. + - real_network: Whether to use a real network. Defaults to false. """ def fake_manager(opts \\ []) do entity_id = Keyword.get(opts, :entity_id, EntitySetup.id()) server_id = Keyword.get(opts, :server_id, ServerSetup.id()) - network_id = Keyword.get(opts, :network_id, NetworkHelper.id()) + + network_id = + cond do + opts[:real_network] -> + {network, _} = NetworkSetup.network(type: :story) + network.network_id + + opts[:network_id] -> + opts[:network_id] + + true -> + NetworkHelper.id() + end manager = %Story.Manager{ diff --git a/test/support/story/vars.ex b/test/support/story/vars.ex new file mode 100644 index 00000000..27783094 --- /dev/null +++ b/test/support/story/vars.ex @@ -0,0 +1,25 @@ +defmodule Helix.Test.Story.Vars do + @moduledoc """ + This helper holds storyline-wide IDs and pointers. Helpful to avoiding + hard-coding stuff on the tests! + """ + + @vars %{ + contact: %{ + friend: "friend" + }, + step: %{ + setup_pc: %{ + name: "setup_pc", + next: "download_cracker", + msg1: "welcome", + msg2: "back_thanks", + msg3: "watchiadoing", + msg4: "hell_yeah", + } + } + } + + def vars, + do: @vars +end