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 62585147..1cf7c01b 100644 --- a/lib/hell/hack.ex +++ b/lib/hell/hack.ex @@ -53,8 +53,8 @@ defmodule HELL.Hack.Experience do {:render, 3} ], "Elixir.Helix.Story.Model.Steppable" => [ - {:start, 2}, - {:setup, 2}, + {:start, 1}, + {:setup, 1}, {:handle_event, 3}, {:complete, 1}, {:restart, 3}, 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..79625674 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 # Generic notifiers diff --git a/lib/software/make/file.ex b/lib/software/make/file.ex index e4280ea5..be6ea8dd 100644 --- a/lib/software/make/file.ex +++ b/lib/software/make/file.ex @@ -18,11 +18,14 @@ 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) @@ -34,7 +37,7 @@ defmodule Helix.Software.Make.File do 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!() diff --git a/lib/software/model/public_ftp.ex b/lib/software/model/public_ftp.ex index 427efdcd..0b85f10f 100644 --- a/lib/software/model/public_ftp.ex +++ b/lib/software/model/public_ftp.ex @@ -84,7 +84,7 @@ defmodule Helix.Software.Model.PublicFTP do @doc """ Verifies whether the given server is active (enabled) or not. """ - def is_active?(pftp = %__MODULE__{is_active: is_active?}), + def is_active?(%__MODULE__{is_active: is_active?}), do: is_active? @spec create_changeset(creation_params) :: diff --git a/lib/story/action/flow/story.ex b/lib/story/action/flow/story.ex index 67c1ae6f..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.start(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 ae849b75..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 :restart + - 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 @@ -100,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({:restart, reason, checkpoint}, step) do - with {:ok, step, events} <- Steppable.restart(step, reason, checkpoint) do - Event.emit(events, from: step.event) + 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 @@ -138,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.start(next_step, prev_step), + {:ok, next_step, events} <- Steppable.start(next_step), Event.emit(events, from: prev_step.event), # Update step meta @@ -153,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. - Emits: StepFailedEvent.t, StepRestartedEvent.t + 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: 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..4e4902f9 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,13 @@ defmodule Helix.Story.Internal.Email do def send_reply(step, reply_id), do: generic_send(step, reply_id, :player) + 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 +131,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/mission/tutorial/steps.ex b/lib/story/mission/tutorial/steps.ex index b8fc05ae..331ebb1e 100644 --- a/lib/story/mission/tutorial/steps.ex +++ b/lib/story/mission/tutorial/steps.ex @@ -20,7 +20,7 @@ defmodule Helix.Story.Mission.Tutorial do empty_setup() - def start(step, _) do + def start(step) do e1 = send_email step, "welcome" {:ok, step, e1} @@ -41,6 +41,7 @@ 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 @@ -56,11 +57,11 @@ defmodule Helix.Story.Mission.Tutorial do on_reply "more_info", send: "give_more_info" - def setup(step, _) do + def setup(step) do # Create the underlying character (@contact) and its server - {:ok, server, %{entity: entity}, e1} = + {:ok, server, _, e1} = setup_once :char, {step.entity_id, @contact} do - result = {:ok, server, %{entity: entity}, events} = + result = {:ok, server, %{entity: entity}, _} = StoryMake.char(step.manager.network_id) ContextAction.save( @@ -99,11 +100,13 @@ 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 + + story_listen cracker.file_id, FileDeletedEvent, :on_file_deleted end events = e1 ++ e2 ++ e3 ++ e4 @@ -111,12 +114,18 @@ defmodule Helix.Story.Mission.Tutorial do {meta, %{}, events} end - def start(step, prev_step) do - {meta, _, e1} = setup(step, prev_step) + # 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} + step = %{step| meta: meta} {:ok, step, e1 ++ e2} end @@ -133,6 +142,12 @@ defmodule Helix.Story.Mission.Tutorial do {:ok, step, []} end + 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 30075112..edefb6a4 100644 --- a/lib/story/model/step.ex +++ b/lib/story/model/step.ex @@ -54,7 +54,7 @@ defmodule Helix.Story.Model.Step do 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 StoryEventHandler. + The action will be interpreted and applied at the StoryHandler. Note that `:restart` also includes metadata (`reason` and `checkpoint`). """ diff --git a/lib/story/model/step/macros.ex b/lib/story/model/step/macros.ex index 29678a4c..ab6d0187 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 @@ -38,7 +39,6 @@ defmodule Helix.Story.Model.Step.Macros do @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) @@ -310,15 +310,13 @@ defmodule Helix.Story.Model.Step.Macros do quote do @doc false - def setup(_, _) do + def setup(_) do nil end end end - alias Helix.Story.Model.Step.Macros.Setup, as: StepSetup - defmacro setup_once(object, identifier, do: block), do: do_setup_once(object, identifier, [], block) defmacro setup_once(object, identifier, opts, do: block), @@ -328,7 +326,8 @@ defmodule Helix.Story.Model.Step.Macros do fun_name = Utils.concat_atom(:find_, object) quote do - result = apply(StepSetup, unquote(fun_name), [unquote(id), unquote(opts)]) + result = + apply(StoryQuery.Setup, unquote(fun_name), [unquote(id), unquote(opts)]) with nil <- result do unquote(block) diff --git a/lib/story/model/step/macros/setup.ex b/lib/story/model/step/macros/setup.ex index 1ba97221..1cdca622 100644 --- a/lib/story/model/step/macros/setup.ex +++ b/lib/story/model/step/macros/setup.ex @@ -1,58 +1,71 @@ defmodule Helix.Story.Model.Step.Macros.Setup do - alias HELL.Utils - 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 - - @spec find_char({Entity.id, Step.contact_id}, []) :: - {:ok, Server.t, %{entity: Entity.t}, []} - | nil - def find_char({entity_id = %Entity.ID{}, contact_id}, _opts) 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 + @doc """ + The `find` macro will generate the standard `find_{item}` method. It's mostly + a syntactic sugar that: - def find_file(nil, _), - do: nil - def find_file(file_id = %File.ID{}, _opts) do - with file = %{} <- FileQuery.fetch(file_id) do - {:ok, file, %{}, []} - end - end + - 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 - def find_pftp_server(nil, _opts), - do: nil - def find_pftp_server(server = %Server{}, opts), - do: find_pftp_server(server.server_id, opts) - def find_pftp_server(server_id = %Server.ID{}, _opts) do - with \ - pftp = %{} <- PublicFTPQuery.fetch_server(server_id), - true <- PublicFTP.is_active?(pftp) || nil - do - {:ok, pftp, %{}, []} end end - def find_pftp_file(nil, _opts), - do: nil - def find_pftp_file(file = %File{}, opts), - do: find_pftp_file(file.file_id, opts) - def find_pftp_file(file_id = %File.ID{}, _opts) do - with pftp_file = %{} <- PublicFTPQuery.fetch_file(file_id) do - {:ok, pftp_file, %{}, []} + 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 747be899..20ee3856 100644 --- a/lib/story/model/step/steppable.ex +++ b/lib/story/model/step/steppable.ex @@ -65,24 +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. - ## Idempotent context creation with `setup/2` + ## Idempotent context creation with `setup/1` - `setup/2` is responsible for generating the entire step environment in an + `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/2` - method. So, for instance, if during the `setup/2` function we generate a file + 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/2` only creates data. See + 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/2` + ## Setting up a new step with `start/1` - The `start/2` function is called when the previous step was completed, and + 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). @@ -91,7 +91,7 @@ defprotocol Helix.Story.Model.Steppable do one of `:ok` | `:error` (where `:error` means something really bad happened internally). - `start/2` will use the `setup/2` method to generate and prepare the step data + `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). @@ -139,7 +139,15 @@ defprotocol Helix.Story.Model.Steppable do ## Restarting a step with `restart/3` - TODO DOCME + 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 @@ -149,7 +157,7 @@ defprotocol Helix.Story.Model.Steppable do alias Helix.Event alias Helix.Story.Model.Step - @spec start(cur_step :: Step.t, prev_step :: Step.t | nil) :: + @spec start(Step.t) :: {:ok | :error, Step.t, [Event.t]} @doc """ Function called when the previous step was completed. It has the purpose of @@ -164,10 +172,25 @@ defprotocol Helix.Story.Model.Steppable do environment generation. It should be logged and debug thoroughly, since no errors should happen during this step. """ - def start(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`. - # TODO DOCME - def setup(step, previous_step) + `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(Step.t, Event.t, Step.meta) :: {Step.callback_action, Step.t, [Event.t]} @@ -192,13 +215,13 @@ defprotocol Helix.Story.Model.Steppable do happen when a specific event was matched and the returned action was `:complete`. - Similar to `start/2`, `: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 restart(Step.t, reason :: atom, checkpoint :: Step.email_id) :: - {:ok | :error, Step.t, [Event.t]} + {:ok | :error, Step.t, Step.email_meta, [Event.t]} @doc """ Method used when the step is restarted. Note that most steps are not restartable, and as such implementing this function is not always necessary. diff --git a/lib/story/model/story/email.ex b/lib/story/model/story/email.ex index 2f487360..4c185745 100644 --- a/lib/story/model/story/email.ex +++ b/lib/story/model/story/email.ex @@ -87,6 +87,35 @@ defmodule Helix.Story.Model.Story.Email do } end + def rollback_email(entry, email_id, meta) do + email = create_email(email_id, meta, :contact) + + entry + |> change() + |> do_rollback_email(email) + |> generic_validations() + end + + 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 format(t) :: t @doc """ 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/test/features/storyline/flow_test.exs b/test/features/storyline/quests/tutorial_test.exs similarity index 98% rename from test/features/storyline/flow_test.exs rename to test/features/storyline/quests/tutorial_test.exs index 56ea824f..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 diff --git a/test/features/storyline/restart_test.exs b/test/features/storyline/restart_test.exs new file mode 100644 index 00000000..efaa581b --- /dev/null +++ b/test/features/storyline/restart_test.exs @@ -0,0 +1,151 @@ +defmodule Helix.Test.Features.Storyline.Restart do + + use Helix.Test.Case.Integration + + import Phoenix.ChannelTest + import Helix.Test.Macros + import Helix.Test.Channel.Macros + import Helix.Test.Story.Macros + + 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.Channel.Setup, as: ChannelSetup + alias Helix.Test.Entity.Helper, as: EntityHelper + alias Helix.Test.Process.TOPHelper + alias Helix.Test.Server.Helper, as: ServerHelper + alias Helix.Test.Story.Helper, as: StoryHelper + alias Helix.Test.Story.Vars, as: StoryVars + + @moduletag :feature + + describe "restart" do + test "flow" do + {server_socket, %{account: account, manager: manager}} = + ChannelSetup.join_storyline_server() + + entity = EntityHelper.fetch_entity_from_account(account) + entity_id = entity.entity_id + + {account_socket, _} = + ChannelSetup.join_account( + account_id: account.account_id, socket: server_socket + ) + + # Inherit storyline variables + s = StoryVars.vars() + + # 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"] + + # 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 + [%{entry: new_entry, 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 + + story_email = StoryQuery.fetch_email(entity_id, step.contact_id) + IO.inspect(story_email) + + + + # # We'll now progress on the first step by replying to the email + # params = + # %{ + # "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) + + # # Now we've proceeded to the next step. + # [story_step_proceeded] = wait_events [:story_step_proceeded] + + # 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) + + # cracker_id = cur_step.meta.cracker_id + # target_id = cur_step.meta.server_id + + # # Now I'll download the requested file + # params = + # %{ + # "file_id" => to_string(cracker_id), + # "ip" => ServerHelper.get_ip(target_id, manager.network_id), + # "network_id" => to_string(manager.network_id) + # } + + # # Start the download (using the PublicFTP) + # ref = push server_socket, "pftp.file.download", params + # assert_reply ref, :ok, _, timeout(:slow) + + # [process_created] = wait_events [:process_created], timeout() + + # # Finish the download + # TOPHelper.force_completion(process_created.data.process_id) + + # # We've proceeded to the next step! + # [story_step_proceeded] = wait_events [:story_step_proceeded] + + # assert_transition \ + # story_step_proceeded, "download_cracker", s.step.setup_pc.name + 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 4cf4a91b..27620a15 100644 --- a/test/story/model/macro_test.exs +++ b/test/story/model/macro_test.exs @@ -65,7 +65,7 @@ defmodule Helix.Story.Model.MacroTest do ) # Generate for the first time (100% misses) - assert {meta, _, _events} = Steppable.setup(step, %{}) + assert {meta, _, _events} = Steppable.setup(step) assert meta.cracker_id assert meta.server_id @@ -73,7 +73,7 @@ defmodule Helix.Story.Model.MacroTest do # Try again, now redoing everything (for idempotency, should be 100% hits) step = %{step| meta: meta} - assert {meta2, _, _events} = Steppable.setup(step, %{}) + assert {meta2, _, _events} = Steppable.setup(step) assert meta2 == meta # Now we'll nuke the `cracker_id`. @@ -83,7 +83,7 @@ defmodule Helix.Story.Model.MacroTest do # As a result, a new `cracker_id` should be generated, but everything else # should be the same - assert {meta3, _, _events} = Steppable.setup(step, %{}) + assert {meta3, _, _events} = Steppable.setup(step) # New cracker was generated refute meta3.cracker_id == meta.cracker_id diff --git a/test/story/quests/tutorial_test.exs b/test/story/quests/tutorial_test.exs index e090cf4c..e9677b23 100644 --- a/test/story/quests/tutorial_test.exs +++ b/test/story/quests/tutorial_test.exs @@ -4,19 +4,15 @@ defmodule Helix.Story.Quests.Tutorial.DownloadCrackerTest do alias Helix.Story.Model.Steppable - alias Helix.Test.Server.Setup, as: ServerSetup - alias Helix.Test.Story.Helper, as: StoryHelper alias Helix.Test.Story.Setup, as: StorySetup - describe "setup/2" do + describe "setup/1" do test "creates the context/environment; idempotent" do {step, _} = StorySetup.step( - name: :tutorial@download_cracker, - meta: %{}, - ready: true + name: :tutorial@download_cracker, meta: %{}, ready: true ) - assert {meta, _, _events} = Steppable.setup(step, %{}) + assert {meta, _, _events} = Steppable.setup(step) assert meta.server_id assert meta.cracker_id @@ -24,8 +20,14 @@ defmodule Helix.Story.Quests.Tutorial.DownloadCrackerTest do # Ensure idempotency step = %{step| meta: meta} - assert {meta2, _, _events} = Steppable.setup(step, %{}) + 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/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 3ec956d2..37e92ea6 100644 --- a/test/support/story/fake_steps.ex +++ b/test/support/story/fake_steps.ex @@ -46,7 +46,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -73,7 +73,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -86,7 +86,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -102,7 +102,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -118,7 +118,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -156,7 +156,7 @@ defmodule Helix.Story.Mission.FakeSteps do empty_setup() - def start(step, _) do + def start(step) do send_email step, "e1" {:ok, step, []} end @@ -166,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 @@ -178,7 +211,7 @@ defmodule Helix.Story.Mission.FakeContactOne do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), @@ -198,7 +231,7 @@ defmodule Helix.Story.Mission.FakeContactTwo do empty_setup() - def start(step, _), + def start(step), do: {:ok, step, []} def complete(step), diff --git a/test/support/story/helper.ex b/test/support/story/helper.ex index f0fcb47d..9473dc20 100644 --- a/test/support/story/helper.ex +++ b/test/support/story/helper.ex @@ -1,7 +1,13 @@ 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_steps(entity_id) do @@ -37,4 +43,53 @@ defmodule Helix.Test.Story.Helper do # Guaranteed to be random "reply_id" 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 end diff --git a/test/support/story/setup.ex b/test/support/story/setup.ex index 4dcefac0..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 """ @@ -64,7 +65,6 @@ 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) # Update step with the newly created manager @@ -72,8 +72,7 @@ defmodule Helix.Test.Story.Setup do step = %{step| manager: manager} # Save the step on DB and `start/2` it - {:ok, _} = StoryAction.proceed_step(step) - {:ok, _, _} = Steppable.start(step, %{}) + {:ok, step} = StoryHelper.start_step(step) related = related @@ -107,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) """ @@ -123,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