diff --git a/lib/account/websocket/channel/account.ex b/lib/account/websocket/channel/account.ex index a414ed5d..088dfd78 100644 --- a/lib/account/websocket/channel/account.ex +++ b/lib/account/websocket/channel/account.ex @@ -12,6 +12,7 @@ channel Helix.Account.Websocket.Channel.Account do alias Helix.Network.Websocket.Requests.Bounce.Create, as: BounceCreateRequest alias Helix.Network.Websocket.Requests.Bounce.Update, as: BounceUpdateRequest alias Helix.Network.Websocket.Requests.Bounce.Remove, as: BounceRemoveRequest + alias Helix.Software.Websocket.Requests.Virus.Collect, as: VirusCollectRequest alias Helix.Story.Websocket.Requests.Email.Reply, as: EmailReplyRequest @doc """ @@ -187,6 +188,51 @@ channel Helix.Account.Websocket.Channel.Account do """ topic "bounce.remove", BounceRemoveRequest + @doc """ + Collects money off of active viruses. + + Params: + *gateway_id: Which gateway server is being used as origin. + *viruses: List of viruses (`File.id`) that we should collect + bounce_id: Which bounce should be used. If omitted, we assume none. + atm_id: Which ATM the account_number belongs to. See [1] and [2]. + account_number: Which account should we send the money to. See [1] and [2]. + wallet: Which bitcoin address should we send the money to. See [1]. + + [1] - Bank account or bitcoin wallet information may be optional if none of + the viruses being collected will use them. For example, if the player is + collecting money from 3 `spyware` viruses, no wallet is required. Similarly, + if all viruses being collected are `miner`, no bank account is required. If + there are both bitcoin-rewarding and cash-rewarding viruses, both payment + information are required. This will be henforced! + + [2] - I don't always need bank account information (see [1]), but when I do, I + require both `atm_id` and `account_number`. This will be henforced as well. + + Returns: :ok + + Events: + - process_created: Emitted *for each virus* when VirusCollectProcess is + created. + - process_create_failed: Emitted when one or more of the underlying collect + processes were not started due to lack of hardware resources. + + Errors: + + Henforcer: + - payment_invalid: Required payment information is missing. + - virus_not_active: One of the viruses at `viruses` isn't active + - virus_not_found: One of the viruses at `viruses` wasn't found + - bank_account_not_belongs: Given `{atm_id, account_number}` does not belong + - bounce_not_belongs: Given `bounce_id` does not belong to the player + - server_not_belongs: Given `gateway_id` does not belong to the player + + Input: + - bad_virus: One of the entries at `viruses` is invalid. + + base errors + """ + topic "virus.collect", VirusCollectRequest + @doc """ Intercepts and handles outgoing events. """ diff --git a/lib/entity/henforcer/entity.ex b/lib/entity/henforcer/entity.ex index 81697adf..8f5ca16f 100644 --- a/lib/entity/henforcer/entity.ex +++ b/lib/entity/henforcer/entity.ex @@ -8,12 +8,16 @@ defmodule Helix.Entity.Henforcer.Entity do alias Helix.Network.Model.Network alias Helix.Network.Query.Network, as: NetworkQuery alias Helix.Software.Henforcer.Storage, as: StorageHenforcer + alias Helix.Software.Henforcer.Virus, as: VirusHenforcer + alias Helix.Software.Model.File alias Helix.Software.Model.Storage + alias Helix.Software.Model.Virus alias Helix.Server.Henforcer.Component, as: ComponentHenforcer alias Helix.Server.Henforcer.Server, as: ServerHenforcer alias Helix.Server.Model.Component alias Helix.Server.Model.Server alias Helix.Server.Query.Component, as: ComponentQuery + alias Helix.Universe.Bank.Model.BankAccount alias Helix.Entity.Model.Entity alias Helix.Entity.Query.Entity, as: EntityQuery @@ -235,4 +239,73 @@ defmodule Helix.Entity.Henforcer.Entity do end |> wrap_relay(%{entity: entity, bounce: bounce}) end + + @type owns_virus_relay :: %{entity: Entity.t, virus: Virus.t} + @type owns_virus_relay_partial :: map + @type owns_virus_error :: + {false, {:virus, :not_belongs}, owns_virus_relay_partial} + | entity_exists_error + | VirusHenforcer.virus_exists_error + + @spec owns_virus?(Entity.idt, File.idt | Virus.t) :: + {true, owns_virus_relay} + | owns_virus_error + @doc """ + Henforces the Entity is the owner (installed) the given virus. + """ + def owns_virus?(entity_id = %Entity.ID{}, virus) do + henforce entity_exists?(entity_id) do + owns_virus?(relay.entity, virus) + end + end + + def owns_virus?(entity, virus_id = %File.ID{}) do + henforce VirusHenforcer.virus_exists?(virus_id) do + owns_virus?(entity, relay.virus) + end + end + + def owns_virus?(entity, virus = %File{}) do + henforce VirusHenforcer.virus_exists?(virus.file_id) do + owns_virus?(entity, relay.virus) + end + end + + def owns_virus?(entity = %Entity{}, virus = %Virus{}) do + if virus.entity_id == entity.entity_id do + reply_ok() + else + reply_error({:virus, :not_belongs}) + end + |> wrap_relay(%{entity: entity, virus: virus}) + end + + @type owns_bank_account_relay :: + %{entity: Entity.t, bank_account: BankAccount.t} + @type owns_bank_account_relay_partial :: map + @type owns_bank_account_error :: + {false, {:bank_account, :not_belongs}, owns_bank_account_relay_partial} + | entity_exists_error + + @spec owns_bank_account?(Entity.idt, BankAccount.t) :: + {true, owns_bank_account_relay} + | owns_bank_account_error + @doc """ + Henforces the Entity is the owner of the given bank account. + """ + def owns_bank_account?(entity_id = %Entity.ID{}, bank_account) do + henforce entity_exists?(entity_id) do + owns_bank_account?(relay.entity, bank_account) + end + end + + def owns_bank_account?(entity = %Entity{}, bank_account = %BankAccount{}) do + # TODO #260 + if to_string(entity.entity_id) == to_string(bank_account.owner_id) do + reply_ok() + else + reply_error({:bank_account, :not_belongs}) + end + |> wrap_relay(%{entity: entity, bank_account: bank_account}) + end end diff --git a/lib/event/dispatcher.ex b/lib/event/dispatcher.ex index 04fbe312..bcf2989b 100644 --- a/lib/event/dispatcher.ex +++ b/lib/event/dispatcher.ex @@ -183,6 +183,8 @@ defmodule Helix.Event.Dispatcher do event SoftwareEvent.Firewall.Stopped event SoftwareEvent.LogForge.LogCreate.Processed event SoftwareEvent.LogForge.LogEdit.Processed + event SoftwareEvent.Virus.Collect.Processed + event SoftwareEvent.Virus.Collected event SoftwareEvent.Virus.Installed event SoftwareEvent.Virus.InstallFailed @@ -227,6 +229,14 @@ defmodule Helix.Event.Dispatcher do LogHandler.Log, :log_forge_conclusion + event SoftwareEvent.Virus.Collect.Processed, + SoftwareHandler.Virus, + :handle_collect + + event SoftwareEvent.Virus.Collected, + BankHandler.Bank.Account, + :virus_collected + ############################################################################## # Story events ############################################################################## @@ -257,6 +267,7 @@ defmodule Helix.Event.Dispatcher do # All event BankEvent.Bank.Account.Login + event BankEvent.Bank.Account.Updated event BankEvent.Bank.Account.Password.Revealed event BankEvent.Bank.Account.Token.Acquired event BankEvent.Bank.Transfer.Processed diff --git a/lib/factor/factor.ex b/lib/factor/factor.ex index cb576360..f0bde36a 100644 --- a/lib/factor/factor.ex +++ b/lib/factor/factor.ex @@ -15,7 +15,7 @@ defmodule Helix.Factor do - Figuring out how long a process should take. - Calculating difficulties, rewards, penalties based on, well, game facts. - One could split the implementation of a game design and balance in two parts: + One could split the implementation of game design and balance in two parts: 1. Gathering of all required variables to calculate the outcome. 2. Calculate the outcome. diff --git a/lib/hell/hell/ecto_macros.ex b/lib/hell/hell/ecto_macros.ex index bab3fc7b..db4c0ef4 100644 --- a/lib/hell/hell/ecto_macros.ex +++ b/lib/hell/hell/ecto_macros.ex @@ -19,4 +19,24 @@ defmodule HELL.Ecto.Macros do end end + + @doc """ + Syntactic-sugar for the less-common Order module + """ + defmacro order(do: block) do + quote do + + defmodule Order do + @moduledoc false + + import Ecto.Query + + alias Ecto.Queryable + alias unquote(__CALLER__.module) + + unquote(block) + end + + end + end end diff --git a/lib/network/henforcer/bounce.ex b/lib/network/henforcer/bounce.ex index 1d4d16e4..d90c91ba 100644 --- a/lib/network/henforcer/bounce.ex +++ b/lib/network/henforcer/bounce.ex @@ -110,7 +110,7 @@ defmodule Helix.Network.Henforcer.Bounce do end end - @type can_use_bounce_relay :: EntityHenforcer.owns_bounce_relay | %{} + @type can_use_bounce_relay :: EntityHenforcer.owns_bounce_relay @type can_use_bounce_relay_partial :: map @type can_use_bounce_error :: EntityHenforcer.owns_bounce_error diff --git a/lib/network/model/connection.ex b/lib/network/model/connection.ex index 7d900bba..666e71c2 100644 --- a/lib/network/model/connection.ex +++ b/lib/network/model/connection.ex @@ -27,6 +27,7 @@ defmodule Helix.Network.Model.Connection do @type public_ftp :: t_of_type(:public_ftp) @type bank_login :: t_of_type(:bank_login) @type wire_transfer :: t_of_type(:wire_transfer) + @type virus_collect :: t_of_type(:virus_collect) @type cracker_bruteforce :: t_of_type(:cracker_bruteforce) @type meta :: map | nil @@ -37,6 +38,7 @@ defmodule Helix.Network.Model.Connection do | :public_ftp | :bank_login | :wire_transfer + | :virus_collect | :cracker_bruteforce @type close_reasons :: :normal | :force diff --git a/lib/process/executable.ex b/lib/process/executable.ex index 3cc1a6ec..6f0c274b 100644 --- a/lib/process/executable.ex +++ b/lib/process/executable.ex @@ -23,6 +23,7 @@ defmodule Helix.Process.Executable do alias Helix.Network.Query.Tunnel, as: TunnelQuery alias Helix.Server.Model.Server alias Helix.Software.Model.File + alias Helix.Universe.Bank.Model.BankAccount alias Helix.Process.Action.Process, as: ProcessAction alias Helix.Process.Model.Process @@ -273,6 +274,10 @@ defmodule Helix.Process.Executable do # Defaults: in case these functions were not defined, we assume the # process is not interested on this (optional) data. + + @spec get_bounce_id(Server.t, Server.t, params, meta) :: + %{bounce_id: Bounce.id | nil} + @doc false defp get_bounce_id(_, _, _, %{bounce: bounce = %Bounce{}}), do: %{bounce_id: bounce.bounce_id} defp get_bounce_id(_, _, _, %{bounce: bounce_id = %Bounce.ID{}}), @@ -280,18 +285,54 @@ defmodule Helix.Process.Executable do defp get_bounce_id(_, _, _, _), do: %{bounce_id: nil} + @spec get_source_connection(Server.t, Server.t, params, meta) :: + {:create, Connection.type} + | nil + @doc false defp get_source_connection(_, _, _, _), do: nil - defp get_source_file(_, _, _, _), - do: %{src_file_id: nil} - + @spec get_target_connection(Server.t, Server.t, params, meta) :: + {:create, Connection.type} + | :same_origin + | nil + @doc false defp get_target_connection(_, _, _, _), do: nil + @spec get_source_file(Server.t, Server.t, params, meta) :: + %{src_file_id: File.id | nil} + @doc false + defp get_source_file(_, _, _, _), + do: %{src_file_id: nil} + + @spec get_target_file(Server.t, Server.t, params, meta) :: + %{tgt_file_id: File.id | nil} + @doc false defp get_target_file(_, _, _, _), do: %{tgt_file_id: nil} + @spec get_source_bank_account(Server.t, Server.t, params, meta) :: + %{ + src_atm_id: Server.t | nil, + src_acc_number: BankAccount.account | nil + } + @doc false + defp get_source_bank_account(_, _, _, _), + do: %{src_atm_id: nil, src_acc_number: nil} + + @spec get_target_bank_account(Server.t, Server.t, params, meta) :: + %{ + tgt_atm_id: Server.t | nil, + tgt_acc_number: BankAccount.account | nil + } + @doc false + defp get_target_bank_account(_, _, _, _), + do: %{tgt_atm_id: nil, tgt_acc_number: nil} + + @spec get_target_process(Server.t, Server.t, params, meta) :: + %{tgt_process_id: Process.t | nil} + @doc false defp get_target_process(_, _, _, _), do: %{tgt_process_id: nil} end @@ -317,6 +358,8 @@ defmodule Helix.Process.Executable do resources = get_resources(unquote_splicing(args)) source_file = get_source_file(unquote_splicing(args)) target_file = get_target_file(unquote_splicing(args)) + source_bank_account = get_source_bank_account(unquote_splicing(args)) + target_bank_account = get_target_bank_account(unquote_splicing(args)) target_process = get_target_process(unquote_splicing(args)) bounce_id = get_bounce_id(unquote_splicing(args)) ownership = get_ownership(unquote_splicing(args)) @@ -324,11 +367,12 @@ defmodule Helix.Process.Executable do network_id = get_network_id(unquote(meta)) partial = - %{} - |> Map.merge(process_data) + process_data |> Map.merge(resources) |> Map.merge(source_file) |> Map.merge(target_file) + |> Map.merge(source_bank_account) + |> Map.merge(target_bank_account) |> Map.merge(target_process) |> Map.merge(bounce_id) |> Map.merge(ownership) @@ -396,10 +440,6 @@ defmodule Helix.Process.Executable do quote do - @spec get_source_connection(term, term, term, term) :: - {:create, Connection.type} - | nil - @doc false defp get_source_connection(unquote_splicing(args)) do unquote(block) end @@ -421,11 +461,6 @@ defmodule Helix.Process.Executable do quote do - @spec get_target_connection(term, term, term, term) :: - {:create, Connection.type} - | :same_origin - | nil - @doc false defp get_target_connection(unquote_splicing(args)) do unquote(block) end @@ -442,9 +477,6 @@ defmodule Helix.Process.Executable do quote do - @spec get_source_file(term, term, term, term) :: - %{src_file_id: File.id | nil} - @doc false defp get_source_file(unquote_splicing(args)) do file_id = unquote(block) @@ -463,9 +495,6 @@ defmodule Helix.Process.Executable do quote do - @spec get_target_file(term, term, term, term) :: - %{tgt_file_id: File.id | nil} - @doc false defp get_target_file(unquote_splicing(args)) do file_id = unquote(block) @@ -475,6 +504,62 @@ defmodule Helix.Process.Executable do end end + @doc """ + Returns the process' `src_atm_id` and `src_acc_number`, as defined on the + `source_bank_account` section of the Process.Executable + """ + defmacro source_bank_account(gateway, target, params, meta, do: block) do + args = [gateway, target, params, meta] + + quote do + + defp get_source_bank_account(unquote_splicing(args)) do + {atm_id, account_number} = + unquote(block) + |> get_bank_data() + + %{ + src_atm_id: atm_id, + src_acc_number: account_number + } + end + + end + end + + @doc """ + Returns the process' `tgt_atm_id` and `tgt_acc_number`, as defined on the + `target_bank_account` section of the Process.Executable + """ + defmacro target_bank_account(gateway, target, params, meta, do: block) do + args = [gateway, target, params, meta] + + quote do + + defp get_target_bank_account(unquote_splicing(args)) do + {atm_id, account_number} = + unquote(block) + |> get_bank_data() + + %{ + tgt_atm_id: atm_id, + tgt_acc_number: account_number + } + end + + end + end + + @spec get_bank_data(BankAccount.t) :: {Server.id, BankAccount.account} + @spec get_bank_data(tuple) :: tuple + @spec get_bank_data(nil) :: {nil, nil} + def get_bank_data(%BankAccount{atm_id: atm_id, account_number: number}), + do: {atm_id, number} + def get_bank_data(result = {_, _}), + do: result + def get_bank_data(nil), + do: {nil, nil} + @doc """ Returns the process' `tgt_process_id`, as defined on the `target_process` section of the Process.Executable. @@ -484,9 +569,6 @@ defmodule Helix.Process.Executable do quote do - @spec get_target_process(term, term, term, term) :: - %{tgt_process_id: Process.t | nil} - @doc false defp get_target_process(unquote_splicing(args)) do process_id = unquote(block) @@ -509,7 +591,7 @@ defmodule Helix.Process.Executable do quote do - @spec get_resources(term, term, term, term) :: + @spec get_resources(Server.t, Server.t, params, meta) :: unquote(process).resources @doc false defp get_resources(unquote_splicing(args)) do diff --git a/lib/process/model/process.ex b/lib/process/model/process.ex index a2212092..a9938606 100644 --- a/lib/process/model/process.ex +++ b/lib/process/model/process.ex @@ -14,6 +14,7 @@ defmodule Helix.Process.Model.Process do import Ecto.Changeset import HELL.Macros + import HELL.Ecto.Macros alias Ecto.Changeset alias HELL.Constant @@ -25,6 +26,7 @@ defmodule Helix.Process.Model.Process do alias Helix.Network.Model.Network alias Helix.Server.Model.Server alias Helix.Software.Model.File + alias Helix.Universe.Bank.Model.BankAccount alias Helix.Process.Model.Processable alias Helix.Process.Model.TOP alias __MODULE__, as: Process @@ -35,15 +37,19 @@ defmodule Helix.Process.Model.Process do gateway_id: Server.id, source_entity_id: Entity.id, target_id: Server.id, - src_connection_id: Connection.id | nil, - src_file_id: File.id | nil, + data: term, + type: type, network_id: Network.id | nil, bounce_id: Bounce.id | nil, + src_connection_id: Connection.id | nil, + src_file_id: File.id | nil, + src_atm_id: Server.id | nil, + src_acc_number: BankAccount.account | nil, tgt_file_id: File.id | nil, tgt_connection_id: Connection.id | nil, + tgt_atm_id: Server.id | nil, + tgt_acc_number: BankAccount.account | nil, tgt_process_id: Process.id | nil, - data: term, - type: type, priority: term, l_allocated: Process.Resources.t | nil, r_allocated: Process.Resources.t | nil, @@ -148,6 +154,18 @@ defmodule Helix.Process.Model.Process do Signal sent when the File that the process is targeting was deleted. Default action is to send itself a SIGKILL with `:tgt_file_deleted` reason. + + ## SIGSRCBANKACCD + + Signal sent when the bank account the process uses as source was closed. + + Default action is to send itself a SIGKILL with `:src_bank_acc_closed` reason. + + ## SIGTGTBANKACCD + + Signal sent when the bank account the process is targeting was closed. + + Default action is to send itself a SIGKILL with `:tgt_bank_acc_closed` reason. """ @type signal :: :SIGTERM @@ -156,9 +174,11 @@ defmodule Helix.Process.Model.Process do | :SIGCONT | :SIGPRIO | :SIGSRCCONND - | :SIGSRCFILED | :SIGTGTCONND + | :SIGSRCFILED | :SIGTGTFILED + | :SIGSRCBANKACCD + | :SIGTGTBANKACCD @typedoc """ Valid params for each type of signal. @@ -179,21 +199,27 @@ defmodule Helix.Process.Model.Process do | :tgt_connection_closed | :src_file_deleted | :tgt_file_deleted + | :src_bank_acc_closed + | :tgt_bank_acc_closed @type changeset :: %Changeset{data: %__MODULE__{}} @type creation_params :: %{ :gateway_id => Server.id, :source_entity_id => Entity.id, - :src_connection_id => Connection.id | nil, - :src_file_id => File.id | nil, :target_id => Server.id, :data => Processable.t, :type => type, :network_id => Network.id | nil, :bounce_id => Bounce.id | nil, + :src_connection_id => Connection.id | nil, + :src_file_id => File.id | nil, + :src_atm_id => Server.id | nil, + :src_acc_number => BankAccount.account | nil, :tgt_file_id => File.id | nil, :tgt_connection_id => Connection.id | nil, + :tgt_atm_id => Server.id | nil, + :tgt_acc_number => BankAccount.account | nil, :tgt_process_id => Process.id | nil, :objective => map, :l_dynamic => dynamic, @@ -204,16 +230,20 @@ defmodule Helix.Process.Model.Process do @creation_fields [ :gateway_id, :source_entity_id, - :src_connection_id, - :src_file_id, :target_id, + :data, + :type, :network_id, :bounce_id, + :src_connection_id, + :src_file_id, + :src_atm_id, + :src_acc_number, :tgt_file_id, :tgt_connection_id, + :tgt_atm_id, + :tgt_acc_number, :tgt_process_id, - :data, - :type, :objective, :static, :l_dynamic, @@ -279,6 +309,16 @@ defmodule Helix.Process.Model.Process do # The server where the target object of this process action is field :target_id, Server.ID + # Which network (if any) is this process bound to + field :network_id, Network.ID, + default: nil + + # Which bounce (if any) is this process bound to + field :bounce_id, Bounce.ID, + default: nil + + ### Custom keys + # Which connection (if any) originated this process field :src_connection_id, Connection.ID, default: nil @@ -287,16 +327,14 @@ defmodule Helix.Process.Model.Process do field :src_file_id, File.ID, default: nil - # Which network (if any) is this process bound to - field :network_id, Network.ID, + # Which ATM id (if any) originated this process + field :src_atm_id, Server.ID, default: nil - # Which bounce (if any) is this process bound to - field :bounce_id, Bounce.ID, + # Which bank account (if any) originated this process + field :src_acc_number, :integer, default: nil - ### Custom keys - # Which file (if any) is the target of this process field :tgt_file_id, File.ID, default: nil @@ -305,6 +343,14 @@ defmodule Helix.Process.Model.Process do field :tgt_connection_id, Connection.ID, default: nil + # Which ATM id (if any) is the target of this process + field :tgt_atm_id, Server.ID, + default: nil + + # Which bank account (if any) is the target of this process + field :tgt_acc_number, :integer, + default: nil + # Which process (if any) is the target of this process field :tgt_process_id, Process.ID, default: nil @@ -651,16 +697,12 @@ defmodule Helix.Process.Model.Process do |> put_change(:creation_time, DateTime.utc_now()) end - defmodule Query do - - import Ecto.Query + query do - alias Ecto.Queryable alias Helix.Software.Model.File alias Helix.Network.Model.Connection alias Helix.Network.Model.Network alias Helix.Server.Model.Server - alias Helix.Process.Model.Process @spec by_id(Queryable.t, Process.idtb) :: Queryable.t diff --git a/lib/software/action/flow/virus.ex b/lib/software/action/flow/virus.ex new file mode 100644 index 00000000..30ad2fde --- /dev/null +++ b/lib/software/action/flow/virus.ex @@ -0,0 +1,52 @@ +defmodule Helix.Software.Action.Flow.Virus do + + alias Helix.Event + alias Helix.Network.Model.Tunnel + alias Helix.Network.Query.Network, as: NetworkQuery + alias Helix.Process.Model.Process + alias Helix.Server.Model.Server + alias Helix.Software.Model.File + alias Helix.Software.Model.Virus + + alias Helix.Software.Process.Virus.Collect, as: VirusCollectProcess + + @internet_id NetworkQuery.internet().network_id + + @type viruses :: [{File.t, Server.t}] + + @spec start_collect( + Server.t, viruses, Tunnel.bounce_id, Virus.payment_info, Event.relay) + :: + [Process.t] + | no_return + @doc """ + Starts the process of collecting money off of active viruses. + + For each virus, a new process and connection will be created, and once each + one gets completed, the collect will be performed. + + Emits: ProcessCreatedEvent + """ + def start_collect(gateway, viruses, bounce_id, {bank_acc, wallet}, relay) do + Enum.reduce(viruses, [], fn {virus, target}, acc -> + + params = + %{ + wallet: wallet, + bank_account: bank_acc + } + + meta = + %{ + virus: virus, + network_id: @internet_id, + bounce: bounce_id + } + + {:ok, process} = + VirusCollectProcess.execute(gateway, target, params, meta, relay) + + acc ++ [process] + end) + end +end diff --git a/lib/software/action/virus.ex b/lib/software/action/virus.ex index 349d284a..eefcde58 100644 --- a/lib/software/action/virus.ex +++ b/lib/software/action/virus.ex @@ -4,7 +4,9 @@ defmodule Helix.Software.Action.Virus do alias Helix.Software.Internal.Virus, as: VirusInternal alias Helix.Software.Model.File alias Helix.Software.Model.Virus + alias Helix.Software.Query.Virus, as: VirusQuery + alias Helix.Software.Event.Virus.Collected, as: VirusCollectedEvent alias Helix.Software.Event.Virus.Installed, as: VirusInstalledEvent alias Helix.Software.Event.Virus.InstallFailed, as: VirusInstallFailedEvent @@ -14,14 +16,31 @@ defmodule Helix.Software.Action.Virus do def install(file, entity_id) do case VirusInternal.install(file, entity_id) do {:ok, virus} -> - event = VirusInstalledEvent.new(file, virus) - - {:ok, virus, [event]} + {:ok, virus, [VirusInstalledEvent.new(file, virus)]} {:error, reason} -> - event = VirusInstallFailedEvent.new(file, entity_id, reason) + {:error, reason, [VirusInstallFailedEvent.new(file, entity_id, reason)]} + end + end - {:error, reason, [event]} + @spec collect(File.t, Virus.payment_info) :: + {:ok, [VirusCollectedEvent.t]} + | {:error, []} + @doc """ + Collects the earnings of the virus (identified by `file`) and transfers them + to the address in `payment_info`. + """ + def collect(file, payment_info) do + with \ + virus = %{} <- VirusQuery.fetch(file.file_id), + earnings = Virus.calculate_earnings(file.software_type, virus, []), + true <- is_integer(earnings), + {:ok, _} <- VirusInternal.set_running_time(virus, 0) + do + {:ok, [VirusCollectedEvent.new(virus, earnings, payment_info)]} + else + _ -> + {:error, []} end end end diff --git a/lib/software/event/handler/virus.ex b/lib/software/event/handler/virus.ex index 2e22ce66..5a981767 100644 --- a/lib/software/event/handler/virus.ex +++ b/lib/software/event/handler/virus.ex @@ -1,18 +1,20 @@ defmodule Helix.Software.Event.Handler.Virus do alias Helix.Event - alias Helix.Software.Action.Virus, as: VirusAction alias Helix.Software.Event.File.Install.Processed, as: FileInstallProcessedEvent + alias Helix.Software.Event.Virus.Collect.Processed, + as: VirusCollectProcessedEvent @doc """ - Handles the completion of FileInstallProcess when the target file is a virus. + Handles the completion of `FileInstallProcess` when the target file is a + virus. Performs a noop if the target file is not a virus. - Emits: VirusInstalledEvent.t, VirusInstallFailedEvent.t + Emits: `VirusInstalledEvent`, `VirusInstallFailedEvent` """ def virus_installed(event = %FileInstallProcessedEvent{backend: :virus}) do case VirusAction.install(event.file, event.entity_id) do @@ -29,4 +31,23 @@ defmodule Helix.Software.Event.Handler.Virus do end def virus_installed(%FileInstallProcessedEvent{backend: _}), do: :noop + + @doc """ + Handles the completion of `VirusCollectProcess`. + + Emits: `VirusCollectedEvent` + """ + def handle_collect(event = %VirusCollectProcessedEvent{}) do + case VirusAction.collect(event.file, event.payment_info) do + {:ok, events} -> + Event.emit(events, from: event) + + :ok + + {:error, events} -> + Event.emit(events, from: event) + + :error + end + end end diff --git a/lib/software/event/virus.ex b/lib/software/event/virus.ex index 44546b2b..c4e3606a 100644 --- a/lib/software/event/virus.ex +++ b/lib/software/event/virus.ex @@ -2,6 +2,68 @@ defmodule Helix.Software.Event.Virus do import Helix.Event + event Collected do + @moduledoc """ + `VirusCollectedEvent` is fired right after the earnings of the virus have + been collected and transferred to the player's bank account/bitcoin wallet. + """ + + alias Helix.Universe.Bank.Model.BankAccount + alias Helix.Software.Model.Virus + + event_struct [:virus, :earnings, :bank_account, :wallet] + + @type t :: + %__MODULE__{ + virus: Virus.t, + earnings: Virus.earnings, + bank_account: BankAccount.t | nil, + wallet: term | nil + } + + @spec new(Virus.t, Virus.earnings, Virus.payment_info) :: + t + def new(virus = %Virus{}, earnings, {bank_acc, wallet}) do + %__MODULE__{ + virus: virus, + earnings: earnings, + bank_account: bank_acc, + wallet: wallet + } + end + + notify do + @moduledoc """ + Notifying that a virus has been collected enables the client to reset the + running time of the virus. + """ + + @event :virus_collected + + @doc false + def generate_payload(event = %{}, _socket) do + data = + event + |> payment_data() + |> Map.merge(%{file_id: to_string(event.virus.file_id)}) + + {:ok, data} + end + + defp payment_data(event = %{bank_account: %BankAccount{}}) do + %{ + atm_id: to_string(event.bank_account.atm_id), + account_number: event.bank_account.account_number, + money: event.earnings + } + end + + @doc false + def whom_to_notify(event), + do: %{account: event.virus.entity_id} + end + end + event Installed do @moduledoc """ `VirusInstalledEvent` is fired when a virus has been installed by someone. diff --git a/lib/software/event/virus/collect.ex b/lib/software/event/virus/collect.ex new file mode 100644 index 00000000..8cf97845 --- /dev/null +++ b/lib/software/event/virus/collect.ex @@ -0,0 +1,50 @@ +defmodule Helix.Software.Event.Virus.Collect do + + import Helix.Event + + event Processed do + @moduledoc """ + `VirusCollectProcessedEvent` is fired when a `VirusCollectProcess` has + completed and we should collect the earnings of the virus identified by + `file`. + """ + + alias Helix.Process.Model.Process + alias Helix.Universe.Bank.Model.BankAccount + alias Helix.Universe.Bank.Query.Bank, as: BankQuery + alias Helix.Software.Model.File + alias Helix.Software.Model.Virus + alias Helix.Software.Query.File, as: FileQuery + + alias Helix.Software.Process.Virus.Collect, as: VirusCollectProcess + + event_struct [:file, :payment_info] + + @type t :: + %__MODULE__{ + file: File.t, + payment_info: Virus.payment_info + } + + @spec new(Process.t, VirusCollectProcess.t) :: + t + def new(process = %Process{}, %VirusCollectProcess{wallet: nil}) do + bank_account = + BankQuery.fetch_account(process.tgt_atm_id, process.tgt_acc_number) + + do_new(process, bank_account, nil) + end + + def new(process = %Process{}, %VirusCollectProcess{wallet: wallet}), + do: do_new(process, nil, wallet) + + @spec do_new(Process.t, BankAccount.t | nil, term | nil) :: + t + defp do_new(process, bank_account, wallet) do + %__MODULE__{ + file: FileQuery.fetch(process.src_file_id), + payment_info: {bank_account, wallet} + } + end + end +end diff --git a/lib/software/henforcer/virus.ex b/lib/software/henforcer/virus.ex index 4fccfa51..7ceac50c 100644 --- a/lib/software/henforcer/virus.ex +++ b/lib/software/henforcer/virus.ex @@ -8,8 +8,30 @@ defmodule Helix.Software.Henforcer.Virus do alias Helix.Software.Henforcer.Storage, as: StorageHenforcer alias Helix.Software.Model.File alias Helix.Software.Model.Storage + alias Helix.Software.Model.Virus alias Helix.Software.Query.Virus, as: VirusQuery + @type virus_exists_relay :: %{virus: Virus.t} + @type virus_exists_relay_partial :: %{} + @type virus_exists_error :: + {false, {:virus, :not_found}, virus_exists_relay_partial} + + @spec virus_exists?(File.id) :: + {true, virus_exists_relay} + | virus_exists_error + @doc """ + Ensures the given `file_id` represents a valid virus. Note it does not check + whether the virus is active, use `is_active?` instead! + """ + def virus_exists?(virus_id = %File.ID{}) do + with virus = %Virus{} <- VirusQuery.fetch(virus_id) do + reply_ok(%{virus: virus}) + else + _ -> + reply_error({:virus, :not_found}) + end + end + @type can_install_relay :: %{file: File.t, entity: Entity.t, storage: Storage.t} @type can_install_relay_partial :: map @@ -43,6 +65,87 @@ defmodule Helix.Software.Henforcer.Virus do end end + @type can_collect_all_relay :: %{viruses: [can_collect_relay]} + @type can_collect_all_relay_partial :: map + @type can_collect_all_error :: can_collect_error + + @spec can_collect_all?(Entity.t, [File.id], Virus.payment_info) :: + {true, can_collect_all_relay} + | can_collect_all_error + @doc """ + Henforces that all given viruses may be collected. + + Under the hood, it simply delegates the verification individually to + `can_collect/3`, and aggregates the results into the `:viruses` relay. + """ + def can_collect_all?(entity = %Entity{}, viruses, payment_info) do + init = {true, %{viruses: []}} + + viruses + |> Enum.reduce_while(init, fn virus_id, {status, acc} -> + with {true, relay} <- can_collect?(entity, virus_id, payment_info) do + relay = drop(relay, :entity) + new_acc = %{viruses: acc.viruses ++ [relay]} + + {:cont, {status, new_acc}} + else + error -> + {:halt, error} + end + end) + end + + @type can_collect_relay :: %{entity: Entity.t, file: File.t, virus: Virus.t} + @type can_collect_relay_partial :: map + @type can_collect_error :: + EntityHenforcer.owns_virus_error + | is_active_error + | valid_payment_error + + @spec can_collect?(Entity.t, File.id, Virus.payment_info) :: + {true, can_collect_relay} + | can_collect_error + @doc """ + Verifies whether `entity` may collect money off of `virus_id`. Ensures that: + + - Virus exists & was installed by the entity + - Virus is currently active + - The payment information at `payment_info` is valid for that virus + - Virus may be used to collect money (e.g. DDoS may not) + """ + def can_collect?(entity = %Entity{}, virus_id = %File.ID{}, payment_info) do + with \ + {true, r1} <- EntityHenforcer.owns_virus?(entity, virus_id), + {true, r2} <- is_active?(virus_id), + file = r2.file, + {true, _} <- valid_payment?(file, payment_info), + {true, _} <- is_collectible?(file) + do + reply_ok(relay(r1, r2)) + end + end + + @type valid_payment_relay :: %{} + @type valid_payment_relay_partial :: %{} + @type valid_payment_error :: + {false, {:payment, :invalid}, valid_payment_relay_partial} + + @spec valid_payment?(File.t, Virus.payment_info) :: + {true, valid_payment_relay} + | valid_payment_error + @doc """ + Verifies whether the given `payment_info` is valid for that specific virus. + + Viruses of type `miner` requires a bitcoin wallet. Viruses of type `spyware` + or `spam` require a bank account. + """ + def valid_payment?(%File{software_type: :virus_miner}, {_, nil}), + do: reply_error({:payment, :invalid}) + def valid_payment?(%File{software_type: :virus_spyware}, {nil, _}), + do: reply_error({:payment, :invalid}) + def valid_payment?(_, _), + do: reply_ok() + @type is_active_relay :: %{file: File.t} @type is_active_relay_partial :: is_active_relay @type is_active_error :: @@ -149,4 +252,24 @@ defmodule Helix.Software.Henforcer.Virus do {:entity, :has_virus_on_storage} ) end + + @type is_collectible_relay :: %{} + @type is_collectible_relay_partial :: %{} + @type is_collectible_error :: + {false, {:virus, :not_collectible}, is_collectible_relay_partial} + + @spec is_collectible?(File.t) :: + {true, is_collectible_relay} + | is_collectible_error + @doc """ + Henforces that the given File is a virus that can be used to collect money. + """ + def is_collectible?(%File{software_type: :virus_spyware}), + do: reply_ok() + def is_collectible?(%File{software_type: :virus_spam}), + do: reply_ok() + def is_collectible?(%File{software_type: :virus_miner}), + do: reply_ok() + def is_collectible?(%File{}), + do: reply_error({:virus, :not_collectible}) end diff --git a/lib/software/internal/virus.ex b/lib/software/internal/virus.ex index e78d25ac..b0431ab6 100644 --- a/lib/software/internal/virus.ex +++ b/lib/software/internal/virus.ex @@ -1,5 +1,6 @@ defmodule Helix.Software.Internal.Virus do + alias HELL.Utils alias Helix.Entity.Model.Entity alias Helix.Software.Model.File alias Helix.Software.Model.Storage @@ -130,12 +131,35 @@ defmodule Helix.Software.Internal.Virus do def activate_virus(virus = %Virus{}, storage_id = %Storage.ID{}) do result = force_activate_virus(virus, storage_id) - # See `install/2` comments on why we have to fetch again. with {:ok, _} <- result do {:ok, fetch(virus.file_id)} end end + @spec set_running_time(Virus.t, seconds :: integer) :: + {:ok, Virus.t} + | {:error, :internal} + @doc """ + Modifies the virus running time. If `seconds` is 0, it will reset to the + current time. This is the most common scenario, and it's the one used when the + virus is collected. + + A positive `seconds` will push the `running_time` towards the future, and a + negative one will push the `running_time` to the past. The latter is useful + for compensating a failed virus collect (albeit uncommon). + """ + def set_running_time(virus = %Virus{}, seconds) do + new_time = Utils.date_before(seconds) + + case update_activation_time(virus, new_time) do + {1, _} -> + {:ok, fetch(virus.file_id)} + + _ -> + {:error, :internal} + end + end + @spec insert_virus(File.t, Entity.id) :: {:ok, Virus.t} | {:error, Virus.changeset} @@ -167,4 +191,13 @@ defmodule Helix.Software.Internal.Virus do on_conflict: :replace_all, conflict_target: [:entity_id, :storage_id] ) end + + @spec update_activation_time(Virus.t, new_time :: DateTime.t) :: + {integer, nil} + | :no_return + defp update_activation_time(virus = %Virus{}, new_time) do + virus.file_id + |> Virus.Active.Query.by_virus() + |> Repo.update_all(set: [activation_time: new_time]) + end end diff --git a/lib/software/model/software.ex b/lib/software/model/software.ex index 91833cda..ffa1f46e 100644 --- a/lib/software/model/software.ex +++ b/lib/software/model/software.ex @@ -22,6 +22,8 @@ defmodule Helix.Software.Model.Software do | :crypto_key | :virus_spyware + @type virus :: :virus_spyware + @type module_name :: cracker_module | firewall_module diff --git a/lib/software/model/virus.ex b/lib/software/model/virus.ex index 265e2c89..54542483 100644 --- a/lib/software/model/virus.ex +++ b/lib/software/model/virus.ex @@ -1,4 +1,8 @@ defmodule Helix.Software.Model.Virus do + @moduledoc """ + The `Virus` model maps all virus installations, telling us which entity + installed the virus. + """ use Ecto.Schema @@ -7,6 +11,8 @@ defmodule Helix.Software.Model.Virus do alias Ecto.Changeset alias Helix.Entity.Model.Entity + alias Helix.Universe.Bank.Model.BankAccount + alias Helix.Software.Model.Software alias Helix.Software.Model.File alias __MODULE__, as: Virus @@ -14,9 +20,19 @@ defmodule Helix.Software.Model.Virus do %__MODULE__{ file_id: File.id, entity_id: Entity.id, - is_active?: boolean + is_active?: boolean, + running_time: seconds :: integer } + @typep wallet :: term + + @type payment_info :: + {BankAccount.t, wallet} + | {nil, wallet} + | {BankAccount.t, nil} + + @type earnings :: BankAccount.amount | float + @type changeset :: %Changeset{data: %__MODULE__{}} @type id :: File.id @@ -40,6 +56,11 @@ defmodule Helix.Software.Model.Virus do virtual: true, default: false + # Time (in seconds) the virus has been running. Only set when active. + field :running_time, :integer, + virtual: true, + default: nil + has_one :active, Virus.Active, foreign_key: :virus_id, references: :file_id @@ -60,21 +81,40 @@ defmodule Helix.Software.Model.Virus do @spec format(t) :: t + @doc """ + Formats the fetched virus, including information about its `active` status. + """ def format(virus = %Virus{}) do - is_active? = + {is_active?, running_time} = if not is_nil(virus.active) and Ecto.assoc_loaded?(virus.active) do - true + time = DateTime.diff(DateTime.utc_now(), virus.active.activation_time) + + {true, time} else - false + {false, nil} end %{virus| is_active?: is_active?, + running_time: running_time, # `active` assoc is, from the VirusInternal above, implementation detail. active: nil} end + @spec calculate_earnings(Software.virus, t, list) :: + earnings + @doc """ + Calculates the earnings of the given viruses based on its type, previous + earnings etc. All of the game balance math is delegated to `Helix.Balance`. + """ + def calculate_earnings( + _virus_type, _virus = %Virus{is_active?: true}, _saved_earnings) + do + # Obviously TODO #389 + 5000 + end + query do alias Helix.Entity.Model.Entity diff --git a/lib/software/model/virus/active.ex b/lib/software/model/virus/active.ex index 9dc608fd..60a6d723 100644 --- a/lib/software/model/virus/active.ex +++ b/lib/software/model/virus/active.ex @@ -1,4 +1,15 @@ defmodule Helix.Software.Model.Virus.Active do + @moduledoc """ + Entries on the `Virus.Active` tell us that the given virus is currently active + and may be used for whatever purpose it serves. + + `:entity_id` and `:storage_id` fields are repeated here, even though we could + get this information from `Virus` and `File` respectively, because: + + 1. It enables an easier querying interface + 2. It enables data integrity features, like creating a unique constraint on + `{entity_id, storage_id}`. + """ use Ecto.Schema @@ -15,7 +26,8 @@ defmodule Helix.Software.Model.Virus.Active do %__MODULE__{ virus_id: Virus.id, entity_id: Entity.id, - storage_id: Storage.id + storage_id: Storage.id, + activation_time: DateTime.t } @type changeset :: %Changeset{data: %__MODULE__{}} @@ -31,6 +43,8 @@ defmodule Helix.Software.Model.Virus.Active do field :entity_id, Entity.ID field :storage_id, Storage.ID + field :activation_time, :utc_datetime + belongs_to :virus, Virus, references: :file_id, foreign_key: :virus_id, @@ -49,9 +63,17 @@ defmodule Helix.Software.Model.Virus.Active do %__MODULE__{} |> cast(params, @creation_fields) + |> put_defaults() |> validate_required(@required_fields) end + @spec put_defaults(changeset) :: + changeset + defp put_defaults(changeset) do + changeset + |> put_change(:activation_time, DateTime.utc_now()) + end + query do alias Helix.Software.Model.Virus diff --git a/lib/software/process/file/transfer.ex b/lib/software/process/file/transfer.ex index 592f7c37..54c9301a 100644 --- a/lib/software/process/file/transfer.ex +++ b/lib/software/process/file/transfer.ex @@ -35,6 +35,7 @@ process Helix.Software.Process.File.Transfer do %{dlk: resource_usage} | %{ulk: resource_usage} + @type process_type :: :file_download | :file_upload @type transfer_type :: :download | :upload @type connection_type :: :ftp | :public_ftp @@ -197,15 +198,20 @@ process Helix.Software.Process.File.Transfer do Defines how FileTransferProcess should be executed. """ + alias Helix.Network.Model.Bounce + alias Helix.Network.Model.Network alias Helix.Software.Model.File alias Helix.Software.Process.File.Transfer, as: FileTransferProcess @type params :: FileTransferProcess.creation_params - @type meta :: %{ - :file => File.t, - optional(atom) => term - } + @type meta :: + %{ + file: File.t, + type: FileTransferProcess.process_type, + network_id: Network.id, + bounce: Bounce.idt | nil + } resources(_, _, params, meta) do %{ diff --git a/lib/software/process/virus/collect.ex b/lib/software/process/virus/collect.ex new file mode 100644 index 00000000..f8e0e52c --- /dev/null +++ b/lib/software/process/virus/collect.ex @@ -0,0 +1,142 @@ +import Helix.Process + +process Helix.Software.Process.Virus.Collect do + @moduledoc """ + `VirusCollectProcess` is the process responsible for rewarding players money + based on their active viruses. + + The process holds information about a single virus, so when collecting `n` + viruses, `n` process (and `n` connections) will be created. + + This process is mostly a thin wrapper, as it should be. Handling of completion + is performed by `VirusHandler` once `VirusCollectProcessedEvent` is fired. + """ + + alias Helix.Universe.Bank.Model.BankAccount + + process_struct [:wallet] + + @process_type :virus_collect + + @type t :: + %__MODULE__{ + wallet: term + } + + @type resources :: + %{ + objective: objective, + l_dynamic: [:cpu], + r_dynamic: [], + static: map + } + + @type objective :: + %{cpu: resource_usage} + + @type creation_params :: + %{ + wallet: term | nil, + bank_account: BankAccount.t | nil + } + + @type resources_params :: map + + @spec new(creation_params) :: + t + def new(%{wallet: wallet}) do + %__MODULE__{ + wallet: wallet + } + end + + @spec resources(resources_params) :: + resources + def resources(params), + do: get_resources params + + processable do + + alias Helix.Software.Event.Virus.Collect.Processed, + as: VirusCollectProcessedEvent + + on_completion(process, data) do + event = VirusCollectProcessedEvent.new(process, data) + + {:delete, [event]} + end + end + + resourceable do + + alias Helix.Software.Process.Virus.Collect, as: VirusCollectProcess + + @type params :: VirusCollectProcess.resources_params + + @type factors :: map + + get_factors(_params) do + end + + cpu(_) do + 500 + end + + static do + %{ + paused: %{ram: 10}, + running: %{ram: 20} + } + end + + dynamic do + [:cpu] + end + end + + executable do + + alias Helix.Network.Model.Bounce + alias Helix.Network.Model.Network + alias Helix.Software.Model.File + alias Helix.Software.Process.Virus.Collect, as: VirusCollectProcess + + @type params :: VirusCollectProcess.creation_params + + @type meta :: + %{ + virus: File.t, + network_id: Network.id, + bounce: Bounce.idt | nil + } + + resources(_, _, _params, _meta) do + %{} + end + + source_file(_, _, _, %{virus: virus}) do + virus.file_id + end + + source_connection(_, _, _, _) do + {:create, :virus_collect} + end + + # There's no bank account when collecting the earnings of a `miner` virus + target_bank_account(_, _, _, %{virus: %{software_type: :virus_miner}}) do + nil + end + + # For any other virus, there must always have a bank account + target_bank_account(_, _, %{bank_account: bank_acc}, _) do + bank_acc + end + end + + process_viewable do + + @type data :: %{} + + render_empty_data() + end +end diff --git a/lib/software/public/virus.ex b/lib/software/public/virus.ex new file mode 100644 index 00000000..7d885800 --- /dev/null +++ b/lib/software/public/virus.ex @@ -0,0 +1,10 @@ +defmodule Helix.Software.Public.Virus do + + alias Helix.Software.Action.Flow.Virus, as: VirusFlow + + @doc """ + Starts a `VirusCollectProcess` for the given viruses. + """ + defdelegate start_collect(gateway, viruses, bounce_id, payment_info, relay), + to: VirusFlow +end diff --git a/lib/software/websocket/requests/virus/collect.ex b/lib/software/websocket/requests/virus/collect.ex new file mode 100644 index 00000000..eb1f1b69 --- /dev/null +++ b/lib/software/websocket/requests/virus/collect.ex @@ -0,0 +1,155 @@ +import Helix.Websocket.Request + +request Helix.Software.Websocket.Requests.Virus.Collect do + + import HELL.Macros + + alias Helix.Cache.Query.Cache, as: CacheQuery + alias Helix.Entity.Henforcer.Entity, as: EntityHenforcer + alias Helix.Network.Henforcer.Bounce, as: BounceHenforcer + alias Helix.Network.Model.Bounce + alias Helix.Server.Model.Server + alias Helix.Server.Query.Server, as: ServerQuery + alias Helix.Universe.Bank.Henforcer.Bank, as: BankHenforcer + alias Helix.Universe.Bank.Model.BankAccount + alias Helix.Software.Model.File + alias Helix.Software.Henforcer.Virus, as: VirusHenforcer + alias Helix.Software.Public.Virus, as: VirusPublic + + def check_params(request, socket) do + # Account information must have either both `{atm_id, acc}` or neither + check_account_info = + fn atm_id, acc -> + (is_nil(atm_id) and is_nil(acc)) or + (not is_nil(atm_id) and not is_nil(acc)) + end + + wallet = nil # #244 + + with \ + {:ok, gateway_id} <- Server.ID.cast(request.unsafe["gateway_id"]), + {:ok, bounce_id} <- cast_optional(request, :bounce_id, &Bounce.ID.cast/1), + {:ok, atm_id} <- cast_optional(request, :atm_id, &Server.ID.cast/1), + {:ok, viruses} <- + cast_list_of_ids(request.unsafe["viruses"], &File.ID.cast/1), + {:ok, account_number} <- + cast_optional(request, :account_number, &BankAccount.cast/1), + + # Ensure that payment info includes at least one of bank account / wallet + # And in the case of bank account, it includes the full information + true <- valid_payment_info?({atm_id, account_number}, wallet), + true <- valid_bank_info?(atm_id, account_number), + + # `viruses` must not be an empty list + false <- Enum.empty?(viruses) + do + params = + %{ + gateway_id: gateway_id, + viruses: viruses, + bounce_id: bounce_id, + atm_id: atm_id, + account_number: account_number, + wallet: wallet + } + + update_params(request, params, reply: true) + else + {:bad_id, _} -> + reply_error(request, :bad_virus) + + _ -> + bad_request(request) + end + end + + def check_permissions(request, socket) do + entity_id = socket.assigns.entity_id + gateway_id = request.params.gateway_id + viruses = request.params.viruses + bounce_id = request.params.bounce_id + atm_id = request.params.atm_id + account_number = request.params.account_number + wallet = request.params.wallet + + with \ + {true, r1} <- EntityHenforcer.owns_server?(entity_id, gateway_id), + entity = r1.entity, + gateway = r1.server, + + {true, r2} <- BankHenforcer.account_exists?(atm_id, account_number), + bank_account = r2.bank_account, + {true, _} <- EntityHenforcer.owns_bank_account?(entity, bank_account), + + payment_info = {bank_account, wallet}, + + {true, r3} <- + VirusHenforcer.can_collect_all?(entity, viruses, payment_info), + viruses = r3.viruses, + + {true, r4} <- BounceHenforcer.can_use_bounce?(entity, bounce_id), + bounce = r4.bounce + do + meta = + %{ + viruses: viruses, + gateway: gateway, + payment_info: payment_info, + bounce: bounce + } + + update_meta(request, meta, reply: true) + else + {false, reason, _} -> + reply_error(request, reason) + end + end + + def handle_request(request, _socket) do + gateway = request.meta.gateway + bounce = request.meta.bounce + payment_info = request.meta.payment_info + viruses = request.meta.viruses + relay = request.relay + + # `VirusPublic.start_collect/5` expects [{File.t, Server.t}] + viruses = + Enum.reduce(viruses, [], fn %{file: file}, acc -> + # OPTIMIZE: There's room for optimization here by bulk-fetching the + # required data. + server = + file.storage_id + |> CacheQuery.from_storage_get_server!() + |> ServerQuery.fetch() + + acc ++ [{file, server}] + end) + + hespawn fn -> + VirusPublic.start_collect( + gateway, viruses, bounce.bounce_id, payment_info, relay + ) + end + + reply_ok(request) + end + + render_empty() + + @spec valid_bank_info?(Server.id | nil, BankAccount.account | nil) :: + boolean + defp valid_bank_info?(nil, nil), + do: true + defp valid_bank_info?(atm, acc) when not is_nil(atm) and not is_nil(acc), + do: true + defp valid_bank_info?(_, _), + do: false + + @spec valid_payment_info?({nil, nil}, nil) :: false + @spec valid_payment_info?({Server.id, BankAccount.account}, nil) :: true + @spec valid_payment_info?(nil, wallet :: term) :: true + defp valid_payment_info?({nil, nil}, nil), + do: false + defp valid_payment_info?(_, _), + do: true +end diff --git a/lib/universe/bank/action/bank.ex b/lib/universe/bank/action/bank.ex index 6834bc4e..831b7a6d 100644 --- a/lib/universe/bank/action/bank.ex +++ b/lib/universe/bank/action/bank.ex @@ -17,8 +17,9 @@ defmodule Helix.Universe.Bank.Action.Bank do alias Helix.Universe.Bank.Query.Bank, as: BankQuery alias Helix.Network.Event.Connection.Closed, as: ConnectionClosedEvent - alias Helix.Universe.Bank.Event.Bank.Account.Login, - as: BankAccountLoginEvent + alias Helix.Universe.Bank.Event.Bank.Account.Login, as: BankAccountLoginEvent + alias Helix.Universe.Bank.Event.Bank.Account.Updated, + as: BankAccountUpdatedEvent alias Helix.Universe.Bank.Event.Bank.Account.Password.Revealed, as: BankAccountPasswordRevealedEvent alias Helix.Universe.Bank.Event.Bank.Account.Token.Acquired, @@ -117,6 +118,28 @@ defmodule Helix.Universe.Bank.Action.Bank do to: BankAccountInternal, as: :close + @spec direct_deposit(BankAccount.t, BankAccount.amount) :: + {:ok, BankAccount.t, [BankAccountUpdatedEvent.t]} + | {:error, :internal} + @doc """ + Performs a direct deposit of $`amount` into `account`. + + NOTE: This is a direct deposit, and is meant for internal mechanics only, like + when the player collects money off of viruses, or when rewards of a mission + should be sent to the player. Not to confuse with direct financial mechanics, + like transferring moneys between player accounts, in which case the underlying + `BankTransferProcess` should be used instead. + """ + def direct_deposit(account, amount) do + case BankAccountInternal.deposit(account, amount) do + {:ok, account} -> + {:ok, account, [BankAccountUpdatedEvent.new(account, :balance)]} + + error -> + error + end + end + @spec generate_token(BankAccount.t, Connection.idt, Entity.idt) :: {:ok, BankToken.t, [BankAccountTokenAcquiredEvent.t]} | {:error, Ecto.Changeset.t} diff --git a/lib/universe/bank/event/bank/account.ex b/lib/universe/bank/event/bank/account.ex index 5448b658..4d1c3ef6 100644 --- a/lib/universe/bank/event/bank/account.ex +++ b/lib/universe/bank/event/bank/account.ex @@ -2,20 +2,76 @@ defmodule Helix.Universe.Bank.Event.Bank.Account do import Helix.Event + event Updated do + @moduledoc """ + `BankAccountUpdatedEvent` is fired when the underlying bank account has + changed. It may happen either due to a balance update (most commonly) or due + to a password change. + """ + + alias Helix.Universe.Bank.Model.BankAccount + + event_struct [:account, :reason] + + @type t :: + %__MODULE__{ + account: BankAccount.t, + reason: reason + } + + @type reason :: :balance | :password + + @spec new(BankAccount.t, reason) :: + t + def new(account = %BankAccount{}, reason) do + %__MODULE__{ + account: account, + reason: reason + } + end + + notify do + @moduledoc """ + Notifies the client of the bank account update, so it can properly update + the local data + """ + + @event :bank_account_updated + + @doc false + def generate_payload(event, _socket) do + data = + %{ + atm_id: to_string(event.account.atm_id), + account_number: event.account.account_number, + balance: event.account.balance, + password: event.account.password, + reason: to_string(event.reason) + } + + {:ok, data} + end + + @doc false + def whom_to_notify(event), + do: %{account: event.account.owner_id} + end + end + event Login do alias Helix.Entity.Model.Entity alias Helix.Universe.Bank.Model.BankAccount alias Helix.Universe.Bank.Model.BankToken + event_struct [:entity_id, :account, :token_id] + @type t :: %__MODULE__{ entity_id: Entity.id, account: BankAccount.t, token_id: BankToken.id | nil } - event_struct [:entity_id, :account, :token_id] - @spec new(BankAccount.t, Entity.id) :: t def new(account = %BankAccount{}, entity_id, token_id \\ nil) do diff --git a/lib/universe/bank/event/handler/bank/account.ex b/lib/universe/bank/event/handler/bank/account.ex index 4f240df7..3589125d 100644 --- a/lib/universe/bank/event/handler/bank/account.ex +++ b/lib/universe/bank/event/handler/bank/account.ex @@ -5,17 +5,19 @@ defmodule Helix.Universe.Bank.Event.Handler.Bank.Account do alias Helix.Event alias Helix.Entity.Query.Entity, as: EntityQuery alias Helix.Universe.Bank.Action.Bank, as: BankAction + + alias Helix.Software.Event.Virus.Collected, as: VirusCollectedEvent alias Helix.Universe.Bank.Event.RevealPassword.Processed, as: RevealPasswordProcessedEvent @doc """ - Handles the conclusion of a PasswordReveal process, described at + Handles the conclusion of a `PasswordRevealProcess`, described at `BankAccountFlow`. Note that actually *displaying* the password to the user - only happens with the BankAccountPasswordRevealedEvent, since the conclusion - of the `PasswordReveal` process does not imply that the password has been + only happens with the `BankAccountPasswordRevealedEvent`, since the conclusion + of the `PasswordRevealProcess` does not imply that the password has been revealed (since the given input may be invalid). - Emits: BankAccountPasswordRevealedEvent + Emits: `BankAccountPasswordRevealedEvent` """ def password_reveal_processed(event = %RevealPasswordProcessedEvent{}) do flowing do @@ -23,11 +25,32 @@ defmodule Helix.Universe.Bank.Event.Handler.Bank.Account do revealed_by = %{} <- EntityQuery.fetch_by_server(event.gateway_id), {:ok, _password, events} <- BankAction.reveal_password( - event.account, - event.token_id, - revealed_by.entity_id + event.account, event.token_id, revealed_by.entity_id ), - on_success(fn -> Event.emit(events) end) + on_success(fn -> Event.emit(events, from: event) end) + do + :ok + end + end + end + + @doc """ + When the rewards of a virus have been successfully collected, it's time to + update their bank account. That's what we do here. + + Notice that the event may be the result of a `miner_virus`, which rewards + bitcoin as opposed to money, and as such we ignore if that's the case. + + Emits: `BankAccountUpdatedEvent` + """ + def virus_collected(%VirusCollectedEvent{bank_account: nil}), + do: :noop + def virus_collected(event = %VirusCollectedEvent{}) do + flowing do + with \ + {:ok, _bank_account, events} <- + BankAction.direct_deposit(event.bank_account, event.earnings), + on_success(fn -> Event.emit(events, from: event) end) do :ok end diff --git a/lib/universe/bank/henforcer/bank.ex b/lib/universe/bank/henforcer/bank.ex new file mode 100644 index 00000000..925abca8 --- /dev/null +++ b/lib/universe/bank/henforcer/bank.ex @@ -0,0 +1,29 @@ +defmodule Helix.Universe.Bank.Henforcer.Bank do + + import Helix.Henforcer + + alias Helix.Server.Model.Server + alias Helix.Universe.Bank.Model.ATM + alias Helix.Universe.Bank.Model.BankAccount + alias Helix.Universe.Bank.Query.Bank, as: BankQuery + + @type account_exists_relay :: %{bank_account: BankAccount.t} + @type account_exists_relay_partial :: %{} + @type account_exists_error :: + {false, {:bank_account, :not_found}, account_exists_relay_partial} + + @spec account_exists?(ATM.id, BankAccount.account) :: + {true, account_exists_relay} + | account_exists_error + @doc """ + Henforces that the given bank account ({atm_id, account_number}) exists. + """ + def account_exists?(atm_id = %Server.ID{}, account_number) do + with bank_acc = %{} <- BankQuery.fetch_account(atm_id, account_number) do + reply_ok(%{bank_account: bank_acc}) + else + _ -> + reply_error({:bank_account, :not_found}) + end + end +end diff --git a/lib/universe/bank/internal/bank_account.ex b/lib/universe/bank/internal/bank_account.ex index b8c06449..f32d3e56 100644 --- a/lib/universe/bank/internal/bank_account.ex +++ b/lib/universe/bank/internal/bank_account.ex @@ -23,9 +23,8 @@ defmodule Helix.Universe.Bank.Internal.BankAccount do a transaction. """ def fetch_for_update(atm, account_number) do - unless Repo.in_transaction?() do - raise "Transaction required in order to acquiring lock" - end + unless Repo.in_transaction?(), + do: raise "Transaction required in order to acquiring lock" atm |> BankAccount.Query.by_atm_account(account_number) diff --git a/lib/universe/bank/internal/bank_transfer.ex b/lib/universe/bank/internal/bank_transfer.ex index 5fdf6944..230e007e 100644 --- a/lib/universe/bank/internal/bank_transfer.ex +++ b/lib/universe/bank/internal/bank_transfer.ex @@ -65,11 +65,9 @@ defmodule Helix.Universe.Bank.Internal.BankTransfer do | {:error, :internal} def complete(transfer) do deposit_money = fn(transfer) -> - account_to = BankAccountInternal.fetch_for_update( - transfer.atm_to, - transfer.account_to) - - BankAccountInternal.deposit(account_to, transfer.amount) + transfer.atm_to + |> BankAccountInternal.fetch_for_update(transfer.account_to) + |> BankAccountInternal.deposit(transfer.amount) end trans = @@ -106,12 +104,10 @@ defmodule Helix.Universe.Bank.Internal.BankTransfer do | {:error, {:transfer, :notfound}} | {:error, :internal} def abort(transfer) do - refund_money = fn(transfer) -> - account_from = BankAccountInternal.fetch_for_update( - transfer.atm_from, - transfer.account_from) - - BankAccountInternal.deposit(account_from, transfer.amount) + refund_money = fn transfer -> + transfer.atm_from + |> BankAccountInternal.fetch_for_update(transfer.account_from) + |> BankAccountInternal.deposit(transfer.amount) end trans = diff --git a/lib/universe/bank/model/bank_account.ex b/lib/universe/bank/model/bank_account.ex index 19ca9589..de2c723c 100644 --- a/lib/universe/bank/model/bank_account.ex +++ b/lib/universe/bank/model/bank_account.ex @@ -20,10 +20,13 @@ defmodule Helix.Universe.Bank.Model.BankAccount do bank_id: NPC.id, atm_id: ATM.id, password: String.t, - balance: non_neg_integer, + balance: balance, owner_id: Account.id } + @type balance :: non_neg_integer + @type amount :: pos_integer + @type creation_params :: %{ bank_id: NPC.idtb, atm_id: ATM.idtb, @@ -89,6 +92,19 @@ defmodule Helix.Universe.Bank.Model.BankAccount do |> put_change(:password, generate_account_password()) end + @spec cast(term) :: + {:ok, account} + | :error + @doc """ + Ensures that the given account number is valid. + + Similar to HELL's PK.cast() + """ + def cast(acc) when is_integer(acc) and acc >= 100_000 and acc <= 999_999, + do: {:ok, acc} + def cast(_), + do: :error + @spec generic_validations(Changeset.t) :: Changeset.t defp generic_validations(changeset) do @@ -98,7 +114,7 @@ defmodule Helix.Universe.Bank.Model.BankAccount do @spec put_defaults(Changeset.t) :: Changeset.t - def put_defaults(changeset) do + defp put_defaults(changeset) do defaults = %{ balance: 0, account_number: generate_account_id(), diff --git a/lib/websocket/flow.ex b/lib/websocket/flow.ex index d90237d2..b363dc33 100644 --- a/lib/websocket/flow.ex +++ b/lib/websocket/flow.ex @@ -122,6 +122,26 @@ defmodule Helix.Websocket.Flow do FlowUtils.validate_input(unquote(input), unquote(element), unquote(opts)) end end + + @doc """ + Helper to cast optional parameters, i.e. parameters that may not exist. + """ + defmacro cast_optional(req, key, cast, default \\ quote(do: {:ok, nil})) do + quote do + FlowUtils.cast_optional( + unquote(req).unsafe, unquote(key), unquote(cast), unquote(default) + ) + end + end + + @doc """ + Helper to cast a list of parameters. + """ + defmacro cast_list_of_ids(elements, cast_function) do + quote do + FlowUtils.cast_list_of_ids(unquote(elements), unquote(cast_function)) + end + end end defmodule Helix.Websocket.Flow.Utils do @@ -192,4 +212,46 @@ defmodule Helix.Websocket.Flow.Utils do :bad_request end end + + @spec cast_optional(map, binary | atom, function, default :: term) :: + {:ok, casted :: term} + | default :: term + @doc """ + Helper that casts optional parameters, falling back to `default` when they + have not been specified. + """ + def cast_optional(unsafe, key, cast_function, default) when is_atom(key), + do: cast_optional(unsafe, to_string(key), cast_function, default) + def cast_optional(unsafe, key, cast_function, default) do + if Map.has_key?(unsafe, key) do + cast_function.(unsafe[key]) + else + default + end + end + + @spec cast_list_of_ids([unsafe_ids :: term] | nil, function) :: + {:ok, [casted_ids :: term]} + | {:bad_id, unsafe_id :: term} + | :bad_request + @doc """ + Helper to automatically cast a list of IDs - it applies `cast_fun` to all + members of `elements`, accumulating the result. + + May return `:bad_request` when input is not a list, or `{:bad_id, unsafe_id}` + when one of the IDs failed to cast. + """ + def cast_list_of_ids(elements, _fun) when not is_list(elements), + do: :bad_request + def cast_list_of_ids(elements, cast_fun) when is_function(cast_fun) do + Enum.reduce_while(elements, {:ok, []}, fn unsafe_id, {_, acc} -> + case cast_fun.(unsafe_id) do + {:ok, element_id} -> + {:cont, {:ok, acc ++ [element_id]}} + + :error -> + {:halt, {:bad_id, unsafe_id}} + end + end) + end end diff --git a/priv/repo/process/migrations/20180214115648_add_bank_data.exs b/priv/repo/process/migrations/20180214115648_add_bank_data.exs new file mode 100644 index 00000000..1bceeda7 --- /dev/null +++ b/priv/repo/process/migrations/20180214115648_add_bank_data.exs @@ -0,0 +1,45 @@ +defmodule Helix.Process.Repo.Migrations.AddBankData do + use Ecto.Migration + + def change do + alter table(:processes, primary_key: false) do + add :src_atm_id, :inet + add :src_acc_number, :integer + + add :tgt_atm_id, :inet + add :tgt_acc_number, :integer + end + + # Add partial indexes on {atm_id, acc_number} for both src_ and tgt_ fields + create index( + :processes, + [:src_atm_id, :src_acc_number], + where: "src_atm_id IS NOT NULL AND src_acc_number IS NOT NULL" + ) + + create index( + :processes, + [:tgt_atm_id, :tgt_acc_number], + where: "tgt_atm_id IS NOT NULL AND tgt_acc_number IS NOT NULL" + ) + + # Makes sure that either BOTH {atm_id, acc_number} are set or NEITHER + create constraint( + :processes, + :valid_src_bank_acc, + check: " + (src_atm_id IS NULL AND src_acc_number IS NULL) OR + (src_atm_id IS NOT NULL AND src_acc_number IS NOT NULL) + " + ) + + create constraint( + :processes, + :valid_tgt_bank_acc, + check: " + (tgt_atm_id IS NULL AND tgt_acc_number IS NULL) OR + (tgt_atm_id IS NOT NULL AND tgt_acc_number IS NOT NULL) + " + ) + end +end diff --git a/priv/repo/software/migrations/20180216064729_add_activation_time.exs b/priv/repo/software/migrations/20180216064729_add_activation_time.exs new file mode 100644 index 00000000..b36c2f22 --- /dev/null +++ b/priv/repo/software/migrations/20180216064729_add_activation_time.exs @@ -0,0 +1,11 @@ +defmodule Helix.Software.Repo.Migrations.AddActivationTime do + use Ecto.Migration + + def change do + alter table(:viruses_active, primary_key: true) do + add :activation_time, :utc_datetime, + null: false, + default: fragment("now()") + end + end +end diff --git a/priv/repo/universe/migrations/20170809142536_add_banks.exs b/priv/repo/universe/migrations/20170809142536_add_banks.exs index 9f640681..8c1897c0 100644 --- a/priv/repo/universe/migrations/20170809142536_add_banks.exs +++ b/priv/repo/universe/migrations/20170809142536_add_banks.exs @@ -8,10 +8,7 @@ defmodule Helix.Universe.Repo.Migrations.AddBanks do # need the unique constraint in order to reference it elsewhere as a FK create table(:banks, primary_key: false) do add :bank_id, - references( - :npcs, - column: :npc_id, - type: :inet), + references(:npcs, column: :npc_id, type: :inet), primary_key: true add :name, :string, null: false end @@ -19,10 +16,7 @@ defmodule Helix.Universe.Repo.Migrations.AddBanks do create table(:atms, primary_key: false) do add :atm_id, :inet, primary_key: true add :bank_id, - references( - :banks, - column: :bank_id, - type: :inet), + references(:banks, column: :bank_id, type: :inet), null: false add :region, :string, null: false end @@ -30,17 +24,11 @@ defmodule Helix.Universe.Repo.Migrations.AddBanks do create table(:bank_accounts, primary_key: false) do add :atm_id, - references( - :atms, - column: :atm_id, - type: :inet), + references(:atms, column: :atm_id, type: :inet), primary_key: true add :account_number, :integer, primary_key: true add :bank_id, - references( - :banks, - column: :bank_id, - type: :inet), + references(:banks, column: :bank_id, type: :inet), null: false add :owner_id, :inet, null: false add :password, :string, null: false diff --git a/test/account/websocket/channel/account/topics/virus_test.exs b/test/account/websocket/channel/account/topics/virus_test.exs new file mode 100644 index 00000000..3879bd00 --- /dev/null +++ b/test/account/websocket/channel/account/topics/virus_test.exs @@ -0,0 +1,72 @@ +defmodule Helix.Account.Websocket.Channel.Account.Topics.VirusTest do + + use Helix.Test.Case.Integration + + import Phoenix.ChannelTest + import Helix.Test.Macros + import Helix.Test.Channel.Macros + + alias Helix.Process.Query.Process, as: ProcessQuery + + alias Helix.Test.Channel.Setup, as: ChannelSetup + alias Helix.Test.Network.Setup, as: NetworkSetup + alias Helix.Test.Process.TOPHelper + alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + alias Helix.Test.Software.Setup, as: SoftwareSetup + + describe "virus.collect" do + test "starts a VirusCollectProcess on all active viruses" do + {socket, %{entity_id: entity_id}} = ChannelSetup.join_account() + {gateway, _} = ServerSetup.server(entity_id: entity_id) + + # Subscribe to the `server` channel, as `ProcessCreatedEvent`s go there + ChannelSetup.join_server( + own_server: true, gateway_id: gateway.server_id, socket: socket + ) + + {_virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity_id, is_active?: true, real_file?: true + ) + + {_virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity_id, is_active?: true, real_file?: true + ) + + bounce = NetworkSetup.Bounce.bounce!(entity_id: entity_id) + bank_account = BankSetup.account!(owner_id: entity_id) + wallet = nil # #244 + + params = + %{ + "gateway_id" => to_string(gateway.server_id), + "viruses" => [to_string(file1.file_id), to_string(file2.file_id)], + "bounce_id" => to_string(bounce.bounce_id), + "atm_id" => to_string(bank_account.atm_id), + "account_number" => bank_account.account_number, + "wallet" => wallet + } + + ref = push socket, "virus.collect", params + assert_reply ref, :ok, %{}, timeout(:slow) + + # Client got the `process_created` events + [process_created1, process_created2] = + wait_events [:process_created, :process_created] + + assert process_created1.data.type == "virus_collect" + assert process_created2.data.type == "virus_collect" + + # Processes are valid! + processes = ProcessQuery.get_processes_on_server(gateway) + process1 = Enum.find(processes, &(&1.src_file_id == file1.file_id)) + process2 = Enum.find(processes, &(&1.src_file_id == file2.file_id)) + assert process1.src_file_id == file1.file_id + assert process2.src_file_id == file2.file_id + + TOPHelper.top_stop() + end + end +end diff --git a/test/entity/henforcer/entity_test.exs b/test/entity/henforcer/entity_test.exs index 84c621f3..c7d657dd 100644 --- a/test/entity/henforcer/entity_test.exs +++ b/test/entity/henforcer/entity_test.exs @@ -12,6 +12,7 @@ defmodule Helix.Entity.Henforcer.EntityTest do alias Helix.Test.Server.Component.Setup, as: ComponentSetup alias Helix.Test.Server.Helper, as: ServerHelper alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup alias Helix.Test.Entity.Setup, as: EntitySetup @internet_id NetworkHelper.internet_id() @@ -140,4 +141,29 @@ defmodule Helix.Entity.Henforcer.EntityTest do assert reason == {:bounce, :not_belongs} end end + + describe "owns_bank_account?/2" do + test "accepts when entity is the owner of the bank account" do + {entity, _} = EntitySetup.entity() + bank_acc = BankSetup.account!(owner_id: entity.entity_id) + + assert {true, relay} = + EntityHenforcer.owns_bank_account?(entity.entity_id, bank_acc) + + assert relay.entity == entity + assert relay.bank_account === bank_acc + + assert_relay relay, [:entity, :bank_account] + end + + test "rejects when entity does not own the bank account" do + {entity, _} = EntitySetup.entity() + bank_acc = BankSetup.account!() + + assert {false, reason, _} = + EntityHenforcer.owns_bank_account?(entity.entity_id, bank_acc) + + assert reason == {:bank_account, :not_belongs} + end + end end diff --git a/test/features/file/transfer_test.exs b/test/features/file/transfer_test.exs index 63cda765..2f4192c5 100644 --- a/test/features/file/transfer_test.exs +++ b/test/features/file/transfer_test.exs @@ -116,6 +116,8 @@ defmodule Helix.Test.Features.File.TransferTest do "from localhost", contains: [dl_file.name] + # TODO: #388 Underlying connection(s) were removed + TOPHelper.top_stop(gateway) end end diff --git a/test/features/virus/collect_test.exs b/test/features/virus/collect_test.exs new file mode 100644 index 00000000..23ea88fe --- /dev/null +++ b/test/features/virus/collect_test.exs @@ -0,0 +1,216 @@ +defmodule Helix.Test.Features.Virus.CollectTest do + + use Helix.Test.Case.Integration + + import Phoenix.ChannelTest + import Helix.Test.Macros + import Helix.Test.Channel.Macros + + alias Helix.Network.Query.Tunnel, as: TunnelQuery + alias Helix.Process.Query.Process, as: ProcessQuery + alias Helix.Software.Model.Virus + alias Helix.Software.Query.Virus, as: VirusQuery + alias Helix.Universe.Bank.Query.Bank, as: BankQuery + + alias Helix.Test.Channel.Setup, as: ChannelSetup + alias Helix.Test.Channel.Request.Helper, as: RequestHelper + alias Helix.Test.Network.Setup, as: NetworkSetup + alias Helix.Test.Process.TOPHelper + alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Software.Setup, as: SoftwareSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + + @moduletag :feature + + describe "virus.collect" do + # NOTE: Install lifecycle tested at `File.InstallTest` + test "collect lifecycle" do + {socket, %{entity_id: entity_id}} = ChannelSetup.join_account() + {gateway, _} = ServerSetup.server(entity_id: entity_id) + + # Subscribe to the `server` channel, as `ProcessCreatedEvent`s go there + ChannelSetup.join_server( + own_server: true, gateway_id: gateway.server_id, socket: socket + ) + + {virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity_id, + is_active?: true, + real_file?: true, + running_time: 600 + ) + + {virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity_id, + is_active?: true, + real_file?: true, + running_time: 6000 + ) + + bounce = NetworkSetup.Bounce.bounce!(entity_id: entity_id) + bank_acc = BankSetup.account!(owner_id: entity_id, balance: :random) + wallet = nil # #244 + + params = + %{ + "gateway_id" => to_string(gateway.server_id), + "viruses" => [to_string(file1.file_id), to_string(file2.file_id)], + "bounce_id" => to_string(bounce.bounce_id), + "atm_id" => to_string(bank_acc.atm_id), + "account_number" => bank_acc.account_number, + "wallet" => wallet, + "request_id" => RequestHelper.id() + } + + # Request to collect the earnings of `virus1` and `virus2` + ref = push socket, "virus.collect", params + assert_reply ref, :ok, response, timeout(:slow) + + # Installation is acknowledge (`:ok`). Contains the `request_id`. + assert response.meta.request_id == params["request_id"] + assert response.data == %{} + + # After a while, client receives the new process through top recalque + [process_created1, process_created2] = + wait_events [:process_created, :process_created] + + # Notifications seem OK + assert process_created1.data.type == "virus_collect" + assert process_created1.meta.request_id == params["request_id"] + assert process_created2.data.type == "virus_collect" + assert process_created2.meta.request_id == params["request_id"] + + # Ensure that both processes were created + processes = ProcessQuery.get_processes_on_server(gateway) + process1 = Enum.find(processes, &(&1.src_file_id == file1.file_id)) + process2 = Enum.find(processes, &(&1.src_file_id == file2.file_id)) + + # Process 1 is OK + assert process1.gateway_id == gateway.server_id + assert process1.source_entity_id == entity_id + assert process1.src_connection_id + assert process1.src_file_id == file1.file_id + assert process1.bounce_id == bounce.bounce_id + assert process1.tgt_atm_id == bank_acc.atm_id + assert process1.tgt_acc_number == bank_acc.account_number + refute process1.data.wallet + + # Process 2 is OK + assert process2.gateway_id == gateway.server_id + assert process2.source_entity_id == entity_id + assert process2.src_connection_id + assert process2.src_file_id == file2.file_id + assert process2.bounce_id == bounce.bounce_id + assert process2.tgt_atm_id == bank_acc.atm_id + assert process2.tgt_acc_number == bank_acc.account_number + refute process2.data.wallet + + # Make sure the connections were created as well + connection1 = TunnelQuery.fetch_connection(process1.src_connection_id) + tunnel1 = TunnelQuery.fetch_from_connection(connection1) + + # Connection has the right type + assert connection1.connection_type == :virus_collect + + # Underlying tunnel was created and it only contains `virus_collect` conn + assert tunnel1.bounce == bounce + assert [connection1] == TunnelQuery.get_connections(tunnel1) + + # Same for `file2`... + connection2 = TunnelQuery.fetch_connection(process2.src_connection_id) + tunnel2 = TunnelQuery.fetch_from_connection(connection2) + + # Connection has the right type + assert connection2.connection_type == :virus_collect + + # Underlying tunnel was created and it only contains `virus_collect` conn + assert tunnel2.bounce == bounce + assert [connection2] == TunnelQuery.get_connections(tunnel2) + + expected_earnings1 = Virus.calculate_earnings(file1, virus1, []) + expected_earnings2 = Virus.calculate_earnings(file2, virus2, []) + + # Now we'll complete the first process + TOPHelper.force_completion(process1) + + [process_completed, virus_collected, bank_account_updated] = + wait_events [ + :process_completed, :virus_collected, :bank_account_updated + ] + + # Ensure `process_id` trail on events + assert process_completed.meta.process_id == + virus_collected.meta.process_id + assert virus_collected.meta.process_id == + bank_account_updated.meta.process_id + + # Ensure `VirusCollectedEvent` is OK + assert virus_collected.data.atm_id == to_string(bank_acc.atm_id) + assert virus_collected.data.account_number == bank_acc.account_number + assert virus_collected.data.money == expected_earnings1 + assert virus_collected.data.file_id == to_string(file1.file_id) + + # Ensure `BankAccountUpdatedEvent` is OK + assert bank_account_updated.data.atm_id == to_string(bank_acc.atm_id) + assert bank_account_updated.data.account_number == bank_acc.account_number + assert bank_account_updated.data.password == bank_acc.password + assert bank_account_updated.data.reason == "balance" + + # Same should happen with the second process + TOPHelper.force_completion(process2) + + [process_completed, virus_collected, bank_account_updated] = + wait_events [ + :process_completed, :virus_collected, :bank_account_updated + ] + + # Ensure `process_id` trail on events + assert process_completed.meta.process_id == + virus_collected.meta.process_id + assert virus_collected.meta.process_id == + bank_account_updated.meta.process_id + + # Ensure `VirusCollectedEvent` is OK + assert virus_collected.data.atm_id == to_string(bank_acc.atm_id) + assert virus_collected.data.account_number == bank_acc.account_number + assert virus_collected.data.money == expected_earnings2 + assert virus_collected.data.file_id == to_string(file2.file_id) + + # Ensure `BankAccountUpdatedEvent` is OK + assert bank_account_updated.data.atm_id == to_string(bank_acc.atm_id) + assert bank_account_updated.data.account_number == bank_acc.account_number + assert bank_account_updated.data.password == bank_acc.password + assert bank_account_updated.data.reason == "balance" + + # BankAccount had its balance updated + new_bank_acc = + BankQuery.fetch_account(bank_acc.atm_id, bank_acc.account_number) + + assert new_bank_acc.balance == + bank_acc.balance + expected_earnings1 + expected_earnings2 + + # Both viruses had their `running_time` reset to 0 + new_virus1 = VirusQuery.fetch(file1.file_id) + assert new_virus1.running_time == 0 + assert new_virus1.is_active? + + new_virus2 = VirusQuery.fetch(file2.file_id) + assert new_virus2.running_time == 0 + assert new_virus2.is_active? + + # Processes no longer exist + assert Enum.empty?(ProcessQuery.get_processes_on_server(gateway)) + + # TODO: #388 + # Underlying connections and tunnels no longer exist as well + # refute TunnelQuery.fetch_connection(process1.src_connection_id) + # refute TunnelQuery.fetch_from_connection(connection1) + # refute TunnelQuery.fetch_connection(process2.src_connection_id) + # refute TunnelQuery.fetch_from_connection(connection2) + + TOPHelper.top_stop() + end + end +end diff --git a/test/software/action/flow/virus_test.exs b/test/software/action/flow/virus_test.exs new file mode 100644 index 00000000..0fa52242 --- /dev/null +++ b/test/software/action/flow/virus_test.exs @@ -0,0 +1,106 @@ +defmodule Helix.Software.Action.Flow.VirusTest do + + use Helix.Test.Case.Integration + + alias Helix.Network.Query.Tunnel, as: TunnelQuery + alias Helix.Software.Action.Flow.Virus, as: VirusFlow + + alias Helix.Test.Network.Setup, as: NetworkSetup + alias Helix.Test.Process.TOPHelper + alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + alias Helix.Test.Software.Helper, as: SoftwareHelper + alias Helix.Test.Software.Setup, as: SoftwareSetup + + @relay nil + + describe "start_collect/5" do + test "starts virus collect" do + {gateway, %{entity: entity}} = ServerSetup.server() + + {target1, _} = ServerSetup.server() + {target2, _} = ServerSetup.server() + + storage1_id = SoftwareHelper.get_storage_id(target1) + storage2_id = SoftwareHelper.get_storage_id(target2) + + {_virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + storage_id: storage1_id, + real_file?: true + ) + + # use a `miner` once available (#244) + {_virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + storage_id: storage2_id, + real_file?: true + ) + + viruses = [{file1, target1}, {file2, target2}] + + {bank_acc, _} = BankSetup.fake_account() + wallet = nil + + {bounce, _} = NetworkSetup.Bounce.bounce() + + processes = + VirusFlow.start_collect( + gateway, viruses, bounce.bounce_id, {bank_acc, wallet}, @relay + ) + + process1 = Enum.find(processes, &(&1.src_file_id == file1.file_id)) + process2 = Enum.find(processes, &(&1.src_file_id == file2.file_id)) + + # Collect of file1: + assert process1.type == :virus_collect + assert process1.gateway_id == gateway.server_id + assert process1.target_id == target1.server_id + assert process1.source_entity_id == entity.entity_id + assert process1.src_connection_id + assert process1.src_file_id == file1.file_id + assert process1.bounce_id == bounce.bounce_id + assert process1.tgt_atm_id == bank_acc.atm_id + assert process1.tgt_acc_number == bank_acc.account_number + refute process1.data.wallet + + # Collect of file2: + assert process2.type == :virus_collect + assert process2.gateway_id == gateway.server_id + assert process2.target_id == target2.server_id + assert process2.source_entity_id == entity.entity_id + assert process2.src_connection_id + assert process2.src_file_id == file2.file_id + assert process2.bounce_id == bounce.bounce_id + assert process2.tgt_atm_id == bank_acc.atm_id + assert process2.tgt_acc_number == bank_acc.account_number + refute process2.data.wallet + + # Make sure the connections were created as well + connection1 = TunnelQuery.fetch_connection(process1.src_connection_id) + tunnel1 = TunnelQuery.fetch_from_connection(connection1) + + # Connection has the right type + assert connection1.connection_type == :virus_collect + + # Underlying tunnel was created and it only contains `virus_collect` conn + assert tunnel1.bounce == bounce + assert [connection1] == TunnelQuery.get_connections(tunnel1) + + # Same for `file2`... + connection2 = TunnelQuery.fetch_connection(process2.src_connection_id) + tunnel2 = TunnelQuery.fetch_from_connection(connection2) + + # Connection has the right type + assert connection2.connection_type == :virus_collect + + # Underlying tunnel was created and it only contains `virus_collect` conn + assert tunnel2.bounce == bounce + assert [connection2] == TunnelQuery.get_connections(tunnel2) + + TOPHelper.top_stop() + end + end +end diff --git a/test/software/action/virus_test.exs b/test/software/action/virus_test.exs new file mode 100644 index 00000000..b800896d --- /dev/null +++ b/test/software/action/virus_test.exs @@ -0,0 +1,45 @@ +defmodule Helix.Software.Action.VirusTest do + + use Helix.Test.Case.Integration + + alias Helix.Software.Action.Virus, as: VirusAction + alias Helix.Software.Model.Virus + alias Helix.Software.Query.Virus, as: VirusQuery + + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + alias Helix.Test.Software.Setup, as: SoftwareSetup + + describe "collect/2" do + test "collects earnings of the virus" do + {virus = %{entity_id: entity_id}, %{file: file}} = + SoftwareSetup.Virus.virus(type: :virus_spyware, running_time: 600) + + # Virus has been running for 10 minutes + assert virus.running_time == 600 + + # And it's expected to earn something + expected_earnings = Virus.calculate_earnings(file, virus, []) + assert expected_earnings > 0 + + bank_acc = BankSetup.fake_account!(owner_id: entity_id) + payment_info = {bank_acc, nil} + + # Collect the rewards + assert {:ok, [virus_collected]} = VirusAction.collect(file, payment_info) + + # Virus event is correct + assert virus_collected.virus.file_id == file.file_id + assert virus_collected.virus.entity_id == entity_id + assert virus_collected.earnings == expected_earnings + assert virus_collected.bank_account == bank_acc + refute virus_collected.wallet + + # Now let's test the side-effects... + virus2 = VirusQuery.fetch(file.file_id) + + # The virus is still active, but had its running time set to 0 + assert virus2.running_time == 0 + assert virus2.is_active? + end + end +end diff --git a/test/software/event/handler/virus_test.exs b/test/software/event/handler/virus_test.exs index 3749b475..f16f7728 100644 --- a/test/software/event/handler/virus_test.exs +++ b/test/software/event/handler/virus_test.exs @@ -6,8 +6,9 @@ defmodule Helix.Software.Event.Handler.VirusTest do alias Helix.Test.Event.Helper, as: EventHelper alias Helix.Test.Event.Setup, as: EventSetup + alias Helix.Test.Software.Setup, as: SoftwareSetup - describe "handling of FileInstallProcessedEvent" do + describe "virus_installed/1" do test "installs the virus" do {event, _} = EventSetup.Software.file_install_processed(:virus) @@ -24,4 +25,21 @@ defmodule Helix.Software.Event.Handler.VirusTest do assert virus.is_active? end end + + describe "handle_collect/1" do + test "collects earnings of money-based virus" do + {_, %{file: file}} = + SoftwareSetup.Virus.virus(type: :virus_spyware, running_time: 600) + + event = EventSetup.Software.Virus.collect_processed(file: file) + + # Emit the `VirusCollectProcessedEvent` + EventHelper.emit(event) + + # Virus running time has been updated + virus = VirusQuery.fetch(file.file_id) + assert virus.running_time == 0 + assert virus.is_active? + end + end end diff --git a/test/software/henforcer/virus_test.exs b/test/software/henforcer/virus_test.exs index 2a5aa5c2..c6a6272a 100644 --- a/test/software/henforcer/virus_test.exs +++ b/test/software/henforcer/virus_test.exs @@ -9,6 +9,7 @@ defmodule Helix.Software.Henforcer.VirusTest do alias Helix.Test.Entity.Setup, as: EntitySetup alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup alias Helix.Test.Software.Helper, as: SoftwareHelper alias Helix.Test.Software.Setup, as: SoftwareSetup @@ -82,4 +83,165 @@ defmodule Helix.Software.Henforcer.VirusTest do assert reason == {:entity, :has_virus_on_storage} end end + + describe "can_collect_all?/3" do + test "handles multiple viruses and accepts when everything is OK" do + {entity, _} = EntitySetup.entity() + + {virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {virus3, %{file: file3}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + viruses = [file1.file_id, file2.file_id, file3.file_id] + + bank_account = BankSetup.fake_account() + + assert {true, relay} = + VirusHenforcer.can_collect_all?(entity, viruses, {bank_account, nil}) + + Enum.each(relay.viruses, fn %{file: file, virus: v} -> + assert Enum.find([file1, file2, file3], &(&1.file_id == file.file_id)) + assert Enum.find([virus1, virus2, virus3], &(&1.file_id == v.file_id)) + end) + + assert_relay relay, [:viruses] + end + + test "rejects when something is wrong" do + {entity, _} = EntitySetup.entity() + + {virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: EntitySetup.id(), + is_active?: true, + real_file?: true + ) + + {_virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + # `virus1` was installed by someone else + refute virus1.entity_id == entity.entity_id + + viruses = [file1.file_id, file2.file_id] + + assert {false, reason, _} = + VirusHenforcer.can_collect_all?(entity, viruses, {nil, nil}) + + assert reason == {:virus, :not_belongs} + end + end + + describe "can_collect?/3" do + test "accepts when everything is OK" do + {entity, _} = EntitySetup.entity() + + {virus, %{file: file}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + bank_account = BankSetup.fake_account() + + assert {true, relay} = + VirusHenforcer.can_collect?(entity, file.file_id, {bank_account, nil}) + + assert relay.virus == virus + assert relay.entity == entity + assert relay.file == file + + assert_relay relay, [:virus, :file, :entity] + end + + test "rejects when entity did not install the virus" do + {entity, _} = EntitySetup.entity() + + {virus, %{file: file}} = + SoftwareSetup.Virus.virus( + entity_id: EntitySetup.id(), # Random entity + is_active?: true, + real_file?: true + ) + + # See? Someone else installed that virus + refute virus.entity_id == entity.entity_id + + bank_account = BankSetup.fake_account() + + assert {false, reason, _} = + VirusHenforcer.can_collect?(entity, file.file_id, {bank_account, nil}) + assert reason == {:virus, :not_belongs} + end + + test "rejects when virus is not active" do + {entity, _} = EntitySetup.entity() + + {virus, %{file: file}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: false, + real_file?: true + ) + + # Not active + refute virus.is_active? + + bank_account = BankSetup.fake_account() + + assert {false, reason, _} = + VirusHenforcer.can_collect?(entity, file.file_id, {bank_account, nil}) + assert reason == {:virus, :not_active} + end + + test "rejects when virus does not exist" do + {entity, _} = EntitySetup.entity() + + assert {false, reason, _} = + VirusHenforcer.can_collect?(entity, SoftwareSetup.id(), {nil, nil}) + assert reason == {:virus, :not_found} + end + + test "rejects when payment is invalid (for bank-based viruses)" do + {entity, _} = EntitySetup.entity() + + {_, %{file: spyware}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true, + type: :virus_spyware + ) + + assert {false, reason, _} = + VirusHenforcer.can_collect?(entity, spyware.file_id, {nil, %{}}) + + assert reason == {:payment, :invalid} + + # TODO: Waiting Bitcoin implementation for full test (#244) + # Also add an extra test on `can_collect_all?/3` + end + end end diff --git a/test/software/internal/virus_test.exs b/test/software/internal/virus_test.exs index 272f8f5a..e84ebfe8 100644 --- a/test/software/internal/virus_test.exs +++ b/test/software/internal/virus_test.exs @@ -32,12 +32,16 @@ defmodule Helix.Software.Internal.VirusTest do # v1 was installed; v2 wasn't (2nd virus by same entity on same storage) assert v1.is_active? + assert v1.running_time + refute v2.is_active? + refute v2.running_time # Storage of `file3` has one virus assert [v3] = VirusInternal.list_by_storage(file3.storage_id) assert v3.file_id == file3.file_id assert v3.is_active? + assert v3.running_time end end @@ -61,6 +65,7 @@ defmodule Helix.Software.Internal.VirusTest do assert Enum.find(viruses, &(&1.file_id == file1.file_id)) assert Enum.find(viruses, &(&1.file_id == file2.file_id)) assert Enum.all?(viruses, &(&1.is_active?)) + assert Enum.all?(viruses, &(&1.running_time)) end end @@ -82,7 +87,7 @@ defmodule Helix.Software.Internal.VirusTest do {:ok, _} = VirusInternal.install(file3, entity_id2) {:ok, _} = VirusInternal.install(file4, entity_id1) - assert viruses = + viruses = VirusInternal.list_by_storage_and_entity(file1.storage_id, entity_id1) # `file1` and `file2` were installed by the given entity on that storage @@ -91,12 +96,17 @@ defmodule Helix.Software.Internal.VirusTest do # v1 is active; v2 isn't (same storage, same entity) assert v1.is_active? + assert v1.running_time + refute v2.is_active? + refute v2.running_time # On the same storage but by entity2, only one virus was found assert [v3] = VirusInternal.list_by_storage_and_entity(file3.storage_id, entity_id2) assert v3.file_id == file3.file_id + assert v3.is_active? + assert v3.running_time end end @@ -109,8 +119,8 @@ defmodule Helix.Software.Internal.VirusTest do assert virus.entity_id == entity_id assert virus.file_id == file.file_id - # assert virus.storage_id == file.storage_id assert virus.is_active? + assert is_integer(virus.running_time) db_entry = VirusInternal.fetch(file.file_id) assert db_entry == virus @@ -124,6 +134,7 @@ defmodule Helix.Software.Internal.VirusTest do # virus1 returned from `install/2` has been formatted and marks as active assert virus1.is_active? + assert is_integer(virus1.running_time) # We have a Virus which is active virus1 = VirusInternal.fetch(file1.file_id) @@ -135,8 +146,8 @@ defmodule Helix.Software.Internal.VirusTest do assert {:ok, virus2} = VirusInternal.install(file2, entity_id) # virus2 is inactive - assert virus1.is_active? refute virus2.is_active? + refute virus2.running_time virus2 = VirusInternal.fetch(file2.file_id) @@ -159,18 +170,38 @@ defmodule Helix.Software.Internal.VirusTest do # `virus1` is active, as it was the first to be added assert virus1.is_active? + assert virus1.running_time # But subsequent `virus2` wasn't activated refute virus2.is_active? + refute virus2.running_time # Let's activate `virus2` assert {:ok, new_virus2} = VirusInternal.activate_virus(virus2, file2.storage_id) + + # Now it's running! assert new_virus2.is_active? + assert new_virus2.running_time # `virus2` is now active and `virus1` isn't refute VirusInternal.is_active?(virus1.file_id) assert VirusInternal.is_active?(virus2.file_id) end end + + describe "set_running_time/2" do + test "modifies the running time of a virus" do + {virus, %{file: _}} = SoftwareSetup.Virus.virus() + + assert {:ok, virus2} = VirusInternal.set_running_time(virus, 60) + assert virus2.running_time == 60 + + assert {:ok, virus3} = VirusInternal.set_running_time(virus, -100) + assert virus3.running_time == -100 + + assert {:ok, virus4} = VirusInternal.set_running_time(virus, 0) + assert virus4.running_time == 0 + end + end end diff --git a/test/software/websocket/requests/virus/collect_test.exs b/test/software/websocket/requests/virus/collect_test.exs new file mode 100644 index 00000000..72a4e369 --- /dev/null +++ b/test/software/websocket/requests/virus/collect_test.exs @@ -0,0 +1,332 @@ +defmodule Helix.Software.Websocket.Requests.Virus.CollectTest do + + use Helix.Test.Case.Integration + + alias Helix.Websocket.Requestable + alias Helix.Process.Query.Process, as: ProcessQuery + alias Helix.Software.Websocket.Requests.Virus.Collect, as: VirusCollectRequest + + alias Helix.Test.Channel.Request.Helper, as: RequestHelper + alias Helix.Test.Channel.Setup, as: ChannelSetup + alias Helix.Test.Network.Helper, as: NetworkHelper + alias Helix.Test.Network.Setup, as: NetworkSetup + alias Helix.Test.Process.TOPHelper + alias Helix.Test.Server.Setup, as: ServerSetup + alias Helix.Test.Universe.Bank.Helper, as: BankHelper + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + alias Helix.Test.Software.Helper, as: SoftwareHelper + alias Helix.Test.Software.Setup, as: SoftwareSetup + + @mock_socket ChannelSetup.mock_account_socket() + + describe "check_params/2" do + test "validates and casts expected data" do + gateway_id = ServerSetup.id() + file1_id = SoftwareSetup.id() + file2_id = SoftwareSetup.id() + bounce_id = NetworkHelper.bounce_id() + atm_id = BankHelper.atm_id() + account_number = BankHelper.account_number() + wallet = nil # 244 + + params = + %{ + "gateway_id" => to_string(gateway_id), + "viruses" => [to_string(file1_id), to_string(file2_id)], + "bounce_id" => to_string(bounce_id), + "atm_id" => to_string(atm_id), + "account_number" => account_number, + "wallet" => wallet + } + + request = VirusCollectRequest.new(params) + + assert {:ok, request} = Requestable.check_params(request, @mock_socket) + + assert request.params.gateway_id == gateway_id + assert request.params.bounce_id == bounce_id + assert request.params.atm_id == atm_id + assert request.params.account_number == account_number + assert request.params.viruses == [file1_id, file2_id] + assert request.params.wallet == wallet + end + + test "rejects when invalid data is given" do + gateway_id = ServerSetup.id() + file1_id = SoftwareSetup.id() + file2_id = SoftwareSetup.id() + bounce_id = NetworkHelper.bounce_id() + atm_id = BankHelper.atm_id() + account_number = BankHelper.account_number() + wallet = nil # 244 + + base_params = + %{ + "gateway_id" => to_string(gateway_id), + "viruses" => [to_string(file1_id), to_string(file2_id)], + "bounce_id" => to_string(bounce_id), + "atm_id" => to_string(atm_id), + "account_number" => account_number, + "wallet" => wallet + } + + # Missing `gateway_id` + p0 = Map.drop(base_params, ["gateway_id"]) + + # Missing `viruses` + p1 = Map.drop(base_params, ["viruses"]) + + # Missing valid payment information + p2 = Map.drop(base_params, ["atm_id", "account_number", "wallet"]) + + # Partial bank account data + p3 = Map.drop(base_params, ["atm_id"]) + + # Invalid entry at `viruses` + p4 = Map.replace(base_params, "viruses", ["lol", to_string(file2_id)]) + + # Viruses must not be empty + p5 = Map.replace(base_params, "viruses", []) + + req0 = VirusCollectRequest.new(p0) + req1 = VirusCollectRequest.new(p1) + req2 = VirusCollectRequest.new(p2) + req3 = VirusCollectRequest.new(p3) + req4 = VirusCollectRequest.new(p4) + req5 = VirusCollectRequest.new(p5) + + assert {:error, reason0, _} = Requestable.check_params(req0, @mock_socket) + assert {:error, reason1, _} = Requestable.check_params(req1, @mock_socket) + assert {:error, reason2, _} = Requestable.check_params(req2, @mock_socket) + assert {:error, reason3, _} = Requestable.check_params(req3, @mock_socket) + assert {:error, reason4, _} = Requestable.check_params(req4, @mock_socket) + assert {:error, reason5, _} = Requestable.check_params(req5, @mock_socket) + + assert reason0 == %{message: "bad_request"} + assert reason1 == reason0 + assert reason2 == reason1 + assert reason3 == reason2 + assert reason5 == reason3 + + assert reason4 == %{message: "bad_virus"} + end + end + + describe "check_permissions/2" do + test "accepts when data is valid" do + {gateway, %{entity: entity}} = ServerSetup.server() + + socket = + ChannelSetup.mock_account_socket( + connect_opts: [entity_id: entity.entity_id] + ) + + {virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + bounce = NetworkSetup.Bounce.bounce!(entity_id: entity.entity_id) + bank_account = BankSetup.account!(owner_id: entity.entity_id) + + params = + %{ + gateway_id: gateway.server_id, + viruses: [file1.file_id, file2.file_id], + bounce_id: bounce.bounce_id, + atm_id: bank_account.atm_id, + account_number: bank_account.account_number, + wallet: nil + } + + request = RequestHelper.mock_request(VirusCollectRequest, params) + + assert {:ok, request} = Requestable.check_permissions(request, socket) + + assert request.meta.gateway == gateway + assert request.meta.payment_info == {bank_account, nil} + assert request.meta.bounce == bounce + assert [ + %{file: file1, virus: virus1}, + %{file: file2, virus: virus2}, + ] == request.meta.viruses + end + + test "rejects when bad things happen" do + # NOTE: Aggregating several test into one to avoid recreating heavy stuff + {gateway, %{entity: entity}} = ServerSetup.server() + {server, _} = ServerSetup.server() + + socket = + ChannelSetup.mock_account_socket( + connect_opts: [entity_id: entity.entity_id] + ) + + {_virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {_virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {_, %{file: inactive}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: false, + real_file?: true + ) + + gateway_storage_id = SoftwareHelper.get_storage_id(gateway) + cracker = SoftwareSetup.cracker!(storage_id: gateway_storage_id) + + bounce = NetworkSetup.Bounce.bounce!(entity_id: entity.entity_id) + bad_bounce = NetworkSetup.Bounce.bounce!() + + bank_account = BankSetup.account!(owner_id: entity.entity_id) + bad_account = BankSetup.account!(atm_id: bank_account.atm_id) + + base_params = + %{ + gateway_id: gateway.server_id, + viruses: [file1.file_id, file2.file_id], + bounce_id: bounce.bounce_id, + atm_id: bank_account.atm_id, + account_number: bank_account.account_number, + wallet: nil + } + + ### Test 0: `gateway_id` is not owned by the entity + p0 = Map.replace(base_params, :gateway_id, server.server_id) + req0 = RequestHelper.mock_request(VirusCollectRequest, p0) + + assert {:error, reason, _} = Requestable.check_permissions(req0, socket) + assert reason == %{message: "server_not_belongs"} + + ### Test 1: BankAccount is not owned by the entity + p1 = Map.replace(base_params, :account_number, bad_account.account_number) + req1 = RequestHelper.mock_request(VirusCollectRequest, p1) + + assert {:error, reason, _} = Requestable.check_permissions(req1, socket) + assert reason == %{message: "bank_account_not_belongs"} + + ### Test 2: Bounce may not be used + p2 = Map.replace(base_params, :bounce_id, bad_bounce.bounce_id) + req2 = RequestHelper.mock_request(VirusCollectRequest, p2) + + assert {:error, reason, _} = Requestable.check_permissions(req2, socket) + assert reason == %{message: "bounce_not_belongs"} + + ### Test 3: A cracker is not a virus! + p3 = Map.replace(base_params, :viruses, [file1.file_id, cracker.file_id]) + req3 = RequestHelper.mock_request(VirusCollectRequest, p3) + + assert {:error, reason, _} = Requestable.check_permissions(req3, socket) + assert reason == %{message: "virus_not_found"} + + ### Test 4: Collecting from a virus that is not active + p4 = Map.replace(base_params, :viruses, [file1.file_id, inactive.file_id]) + req4 = RequestHelper.mock_request(VirusCollectRequest, p4) + + assert {:error, reason, _} = Requestable.check_permissions(req4, socket) + assert reason == %{message: "virus_not_active"} + + ### Test 5: Missing payment information + # TODO #244 + end + end + + describe "handle_request/2" do + test "starts collect" do + {gateway, %{entity: entity}} = ServerSetup.server() + + socket = + ChannelSetup.mock_account_socket( + connect_opts: [entity_id: entity.entity_id] + ) + + {virus1, %{file: file1}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + {virus2, %{file: file2}} = + SoftwareSetup.Virus.virus( + entity_id: entity.entity_id, + is_active?: true, + real_file?: true + ) + + bounce = NetworkSetup.Bounce.bounce!(entity_id: entity.entity_id) + bank_account = BankSetup.account!(owner_id: entity.entity_id) + + params = + %{ + gateway_id: gateway.server_id, + viruses: [file1.file_id, file2.file_id], + bounce_id: bounce.bounce_id, + atm_id: bank_account.atm_id, + account_number: bank_account.account_number, + wallet: nil + } + + meta = + %{ + gateway: gateway, + payment_info: {bank_account, nil}, + bounce: bounce, + viruses: [ + %{file: file1, virus: virus1}, %{file: file2, virus: virus2} + ] + } + + request = RequestHelper.mock_request(VirusCollectRequest, params, meta) + + # There's nothing we can do with the response because it's async + assert {:ok, _} = Requestable.handle_request(request, socket) + + # So let's make sure the processes were created + processes = ProcessQuery.get_processes_on_server(gateway) + + process1 = Enum.find(processes, &(&1.src_file_id == file1.file_id)) + process2 = Enum.find(processes, &(&1.src_file_id == file2.file_id)) + + assert process1.gateway_id == gateway.server_id + assert process1.source_entity_id == entity.entity_id + assert process1.src_connection_id + assert process1.src_file_id == file1.file_id + assert process1.bounce_id == bounce.bounce_id + assert process1.tgt_atm_id == bank_account.atm_id + assert process1.tgt_acc_number == bank_account.account_number + refute process1.data.wallet + + assert process2.gateway_id == gateway.server_id + assert process2.source_entity_id == entity.entity_id + assert process2.src_connection_id + assert process2.src_file_id == file2.file_id + assert process2.bounce_id == bounce.bounce_id + assert process2.tgt_atm_id == bank_account.atm_id + assert process2.tgt_acc_number == bank_account.account_number + refute process2.data.wallet + + TOPHelper.top_stop() + end + end +end diff --git a/test/support/channel/macros.ex b/test/support/channel/macros.ex index 37ee490d..6657ee46 100644 --- a/test/support/channel/macros.ex +++ b/test/support/channel/macros.ex @@ -59,6 +59,8 @@ defmodule Helix.Test.Channel.Macros do defmacro list_events(timeout \\ quote(do: 50)) do quote do unquote(wait_all(timeout)) + # credo:disable-for-next-line + |> IO.inspect() end end end diff --git a/test/support/channel/request/helper.ex b/test/support/channel/request/helper.ex index dc12c522..9c232e1e 100644 --- a/test/support/channel/request/helper.ex +++ b/test/support/channel/request/helper.ex @@ -1,5 +1,6 @@ defmodule Helix.Test.Channel.Request.Helper do + alias HELL.TestHelper.Random alias Helix.Test.Channel.Setup, as: ChannelSetup @mock_socket ChannelSetup.mock_account_socket() @@ -12,4 +13,10 @@ defmodule Helix.Test.Channel.Request.Helper do relay: Helix.Websocket.Request.Relay.new(params, @mock_socket) } end + + @doc """ + Generates a random request ID + """ + def id, + do: Random.string(max: 256) end diff --git a/test/support/event/setup/software/virus.ex b/test/support/event/setup/software/virus.ex new file mode 100644 index 00000000..e63bc106 --- /dev/null +++ b/test/support/event/setup/software/virus.ex @@ -0,0 +1,44 @@ +defmodule Helix.Test.Event.Setup.Software.Virus do + + alias Helix.Software.Event.Virus.Collect.Processed, + as: VirusCollectProcessedEvent + alias Helix.Software.Event.Virus.Collected, as: VirusCollectedEvent + + alias Helix.Test.Universe.Bank.Helper, as: BankHelper + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + alias Helix.Test.Software.Setup, as: SoftwareSetup + + @doc """ + Opts: + - virus: Specify origin virus (`Virus.t`). Defaults to generating fake virus + - earnings: Specify total earnings. Defaults to cash-based earnings + - bank_account: Set which bank account to use. Defaults to fake bank account. + - wallet: Set which wallet to use. Defaults to `nil` + """ + def collected(opts \\ []) do + virus = Keyword.get(opts, :virus, SoftwareSetup.Virus.fake_virus!()) + earnings = Keyword.get(opts, :earnings, BankHelper.amount()) + bank_acc = Keyword.get(opts, :bank_account, BankSetup.fake_account!()) + wallet = Keyword.get(opts, :wallet, nil) + + VirusCollectedEvent.new(virus, earnings, {bank_acc, wallet}) + end + + @doc """ + Opts: + - virus: Specify origin file (`File.t`). REQUIRED. + - earnings: Specify total earnings. Defaults to cash-based earnings + - bank_account: Set which bank account to use. Defaults to fake bank account. + - wallet: Set which wallet to use. Defaults to `nil` + """ + def collect_processed(opts \\ []) do + file = Keyword.fetch!(opts, :file) + bank_acc = Keyword.get(opts, :bank_account, BankSetup.fake_account!()) + wallet = Keyword.get(opts, :wallet, nil) + + %VirusCollectProcessedEvent{ + file: file, + payment_info: {bank_acc, wallet} + } + end +end diff --git a/test/support/network/bounce/setup.ex b/test/support/network/bounce/setup.ex index 6d9ec1b7..f80d0d55 100644 --- a/test/support/network/bounce/setup.ex +++ b/test/support/network/bounce/setup.ex @@ -77,7 +77,8 @@ defmodule Helix.Test.Network.Setup.Bounce do name: name, entity_id: entity_id, sorted: sorted - } |> Changeset.change() + } + |> Changeset.change() bounce = changeset diff --git a/test/support/network/helper.ex b/test/support/network/helper.ex index 400c888f..d062597f 100644 --- a/test/support/network/helper.ex +++ b/test/support/network/helper.ex @@ -33,6 +33,12 @@ defmodule Helix.Test.Network.Helper do def connection_id, do: Connection.ID.generate() + @doc """ + Generates a random bounce ID + """ + def bounce_id, + do: Bounce.ID.generate() + @doc """ Generates a random IP """ diff --git a/test/support/software/helper.ex b/test/support/software/helper.ex index 4fdf774f..cbcf37bb 100644 --- a/test/support/software/helper.ex +++ b/test/support/software/helper.ex @@ -5,10 +5,20 @@ defmodule Helix.Test.Software.Helper do alias Helix.Software.Model.File alias Helix.Software.Model.Software alias Helix.Software.Model.Storage + alias Helix.Software.Model.Virus alias Helix.Software.Query.Storage, as: StorageQuery + alias Helix.Software.Repo, as: SoftwareRepo alias HELL.TestHelper.Random + @doc """ + Returns raw DB entry for testing/debugging. + """ + def raw_get(virus = %Virus{}), + do: SoftwareRepo.get(Virus, virus.file_id) + def raw_get(virus = %Virus{}, :active), + do: SoftwareRepo.get(Virus.Active, virus.file_id) + @doc """ Returns the first `Storage.t` of the given server """ @@ -75,9 +85,8 @@ defmodule Helix.Test.Software.Helper do def random_file_size, do: Enum.random(1..200) - def random_file_name do - Burette.Color.name() - end + def random_file_name, + do: Burette.Color.name() def random_file_path do 1..5 diff --git a/test/support/software/setup.ex b/test/support/software/setup.ex index 7079d529..1e204729 100644 --- a/test/support/software/setup.ex +++ b/test/support/software/setup.ex @@ -249,6 +249,9 @@ defmodule Helix.Test.Software.Setup do file(type: :crypto_key) end + @doc """ + Generates a `File.ID` + """ def id, do: File.ID.generate() end diff --git a/test/support/software/setup/virus.ex b/test/support/software/setup/virus.ex index 17bb3565..2f096589 100644 --- a/test/support/software/setup/virus.ex +++ b/test/support/software/setup/virus.ex @@ -2,6 +2,7 @@ defmodule Helix.Test.Software.Setup.Virus do alias Ecto.Changeset alias Helix.Entity.Model.Entity + alias Helix.Software.Internal.File, as: FileInternal alias Helix.Software.Internal.Virus, as: VirusInternal alias Helix.Software.Model.File alias Helix.Software.Model.Virus @@ -17,9 +18,30 @@ defmodule Helix.Test.Software.Setup.Virus do virus = SoftwareRepo.insert!(fake_virus) - if virus.is_active? do - {:ok, _} = VirusInternal.activate_virus(virus, file.storage_id) - end + {virus, file} = + if virus.is_active? do + {:ok, new_virus} = VirusInternal.activate_virus(virus, file.storage_id) + + # Fetch again to update the File's metadata (since it got installed) + new_file = FileInternal.fetch(file.file_id) + + {new_virus, new_file} + else + {virus, file} + end + + # Possibly fetch again in case the user requested a custom `running_time` + virus = + if opts[:running_time] do + {:ok, new_virus} = + VirusInternal.set_running_time(virus, opts[:running_time]) + + new_virus + else + virus + end + + related = Map.replace(related, :file, file) {virus, related} end @@ -32,6 +54,7 @@ defmodule Helix.Test.Software.Setup.Virus do - is_active?: Whether to mark virus as active. Defaults to true. - real_file?: Whether to generate the underlying virus file. Defaults to true - type: Virus type. Defaults to `spyware`. Only used when `real_file?` is set + - running_time: Set for how long the virus have been running. Defaults to 0s. Related: File.t (when `real_life?` is set) """ @@ -62,6 +85,7 @@ defmodule Helix.Test.Software.Setup.Virus do file_id: file_id, is_active?: is_active? } + |> Map.replace(:active, nil) related = %{ @@ -71,4 +95,9 @@ defmodule Helix.Test.Software.Setup.Virus do {virus, related} end + + def fake_virus!(opts \\ []) do + {virus, _} = fake_virus(opts) + virus + end end diff --git a/test/support/universe/bank/helper.ex b/test/support/universe/bank/helper.ex new file mode 100644 index 00000000..c5a65240 --- /dev/null +++ b/test/support/universe/bank/helper.ex @@ -0,0 +1,23 @@ +defmodule Helix.Test.Universe.Bank.Helper do + + alias HELL.TestHelper.Random + alias Helix.Test.Server.Setup, as: ServerSetup + + @doc """ + Generates a random bank account number + """ + def account_number, + do: Random.number(min: 100_000, max: 999_999) + + @doc """ + Generates a random ATM ID + """ + def atm_id, + do: ServerSetup.id() + + @doc """ + Generates a random amount of money + """ + def amount, + do: Random.number(min: 1, max: 5000) +end diff --git a/test/support/universe/bank/setup.ex b/test/support/universe/bank/setup.ex index 7892549f..d393d874 100644 --- a/test/support/universe/bank/setup.ex +++ b/test/support/universe/bank/setup.ex @@ -1,5 +1,7 @@ +# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity defmodule Helix.Test.Universe.Bank.Setup do + alias Helix.Account.Model.Account alias Helix.Account.Model.Account alias Helix.Entity.Model.Entity alias Helix.Network.Model.Connection @@ -11,11 +13,11 @@ defmodule Helix.Test.Universe.Bank.Setup do alias Helix.Universe.Bank.Model.BankTransfer alias Helix.Universe.Repo, as: UniverseRepo - alias HELL.TestHelper.Random alias Helix.Test.Account.Setup, as: AccountSetup alias Helix.Test.Network.Setup, as: NetworkSetup alias Helix.Test.Server.Setup, as: ServerSetup alias Helix.Test.Universe.NPC.Helper, as: NPCHelper + alias Helix.Test.Universe.Bank.Helper, as: BankHelper @doc """ See doc on `fake_account/1` @@ -38,7 +40,7 @@ defmodule Helix.Test.Universe.Bank.Setup do care that the resulting atm is constant. For instance, atm on atm_seq=1 is different from atm on atm_seq=2 - owner_id: Player who owns that account. It's OK to pass an Entity.ID - - balance: Starting balance of that account. Defaults to 0 + - balance: Starting balance of that account. Defaults to 0. Accepts `:random` - number: Bank account number. """ def fake_account(opts \\ []) do @@ -65,11 +67,18 @@ defmodule Helix.Test.Universe.Bank.Setup do Account.ID.generate() end - number = Access.get( - opts, - :number, - Random.number(min: 100_000, max: 999_999)) - balance = Access.get(opts, :balance, 0) + number = Keyword.get(opts, :number, BankHelper.account_number()) + balance = + cond do + opts[:balance] == :random -> + BankHelper.amount() + + opts[:balance] -> + opts[:balance] + + true -> + 0 + end acc = %BankAccount{ @@ -85,17 +94,22 @@ defmodule Helix.Test.Universe.Bank.Setup do {acc, %{}} end + @doc false + def fake_account!(opts \\ []) do + {account, _} = fake_account(opts) + account + end + @doc """ See doc on `fake_transfer/1` """ def transfer(opts \\ []) do {transfer, related = %{acc1: acc1, acc2: acc2}} = fake_transfer(opts) + {:ok, inserted} = BankTransferInternal.start( - acc1, - acc2, - transfer.amount, - transfer.started_by) + acc1, acc2, transfer.amount, transfer.started_by + ) {inserted, related} end @@ -114,9 +128,9 @@ defmodule Helix.Test.Universe.Bank.Setup do ignored, respectively. """ def fake_transfer(opts \\ []) do - amount = Access.get(opts, :amount, Random.number(min: 1, max: 5000)) - balance1 = Access.get(opts, :balance1, amount) - balance2 = Access.get(opts, :balance2, 0) + amount = Keyword.get(opts, :amount, BankHelper.amount()) + balance1 = Keyword.get(opts, :balance1, amount) + balance2 = Keyword.get(opts, :balance2, 0) acc1 = if opts[:acc1] do @@ -132,9 +146,9 @@ defmodule Helix.Test.Universe.Bank.Setup do account!([balance: balance2]) end - started_by = Random.pk() + started_by = Account.ID.generate() - transfer_id = Access.get(opts, :transfer_id, BankTransfer.ID.generate()) + transfer_id = Keyword.get(opts, :transfer_id, BankTransfer.ID.generate()) transfer = %BankTransfer{ @@ -169,7 +183,7 @@ defmodule Helix.Test.Universe.Bank.Setup do Related data: BankAccount.t """ def fake_token(opts \\ []) do - connection_id = Access.get(opts, :connection_id, Connection.ID.generate()) + connection_id = Keyword.get(opts, :connection_id, Connection.ID.generate()) acc = if opts[:acc] do opts[:acc] @@ -250,11 +264,7 @@ defmodule Helix.Test.Universe.Bank.Setup do # Login with the right password {:ok, connection} = BankAccountFlow.login_password( - acc.atm_id, - acc.account_number, - server.server_id, - nil, - acc.password + acc.atm_id, acc.account_number, server.server_id, nil, acc.password ) {connection, %{acc: acc, server: server, entity: entity}} diff --git a/test/universe/bank/action/bank_test.exs b/test/universe/bank/action/bank_test.exs index 32c0e7e3..ba1021d4 100644 --- a/test/universe/bank/action/bank_test.exs +++ b/test/universe/bank/action/bank_test.exs @@ -11,15 +11,15 @@ defmodule Helix.Universe.Bank.Action.BankTest do alias Helix.Universe.Bank.Action.Bank, as: BankAction alias Helix.Universe.Bank.Internal.BankAccount, as: BankAccountInternal alias Helix.Universe.Bank.Internal.BankTransfer, as: BankTransferInternal - alias Helix.Universe.Bank.Query.Bank, as: BankQuery alias Helix.Test.Account.Setup, as: AccountSetup alias Helix.Test.Event.Setup, as: EventSetup alias Helix.Test.Network.Setup, as: NetworkSetup alias Helix.Test.Server.Setup, as: ServerSetup - alias Helix.Test.Universe.Bank.Setup, as: BankSetup alias Helix.Test.Universe.NPC.Helper, as: NPCHelper + alias Helix.Test.Universe.Bank.Helper, as: BankHelper + alias Helix.Test.Universe.Bank.Setup, as: BankSetup describe "start_transfer/4" do test "with valid data" do @@ -383,6 +383,25 @@ defmodule Helix.Universe.Bank.Action.BankTest do end end + describe "direct_deposit/2" do + test "updates the account balance" do + acc = BankSetup.account!(balance: :random) + amount = BankHelper.amount() + + assert {:ok, new_acc, [event]} = BankAction.direct_deposit(acc, amount) + + # Updated the balance + assert new_acc.balance == acc.balance + amount + + # Event is correct + assert event.account == new_acc + assert event.reason == :balance + + # And just for the sake of it, change has been persisted on the DB + assert new_acc == BankQuery.fetch_account(acc.atm_id, acc.account_number) + end + end + defp generate_bank_login_meta(account) do %{ "atm_id" => to_string(account.atm_id), diff --git a/test/universe/bank/event/handler/bank/account_test.exs b/test/universe/bank/event/handler/bank/account_test.exs new file mode 100644 index 00000000..7c4120f4 --- /dev/null +++ b/test/universe/bank/event/handler/bank/account_test.exs @@ -0,0 +1,27 @@ +defmodule Helix.Universe.Bank.Event.Handler.Bank.AccountTest do + + use Helix.Test.Case.Integration + + alias Helix.Universe.Bank.Query.Bank, as: BankQuery + + alias Helix.Test.Event.Helper, as: EventHelper + alias Helix.Test.Event.Setup, as: EventSetup + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + + describe "virus_collected/1" do + test "updates the account balance" do + bank_acc = BankSetup.account!(balance: :random) + + event = EventSetup.Software.Virus.collected(bank_account: bank_acc) + + # Emit the event + EventHelper.emit(event) + + new_bank_acc = + BankQuery.fetch_account(bank_acc.atm_id, bank_acc.account_number) + + # Balance was updated + assert new_bank_acc.balance == bank_acc.balance + event.earnings + end + end +end diff --git a/test/universe/bank/henforcer/bank_test.exs b/test/universe/bank/henforcer/bank_test.exs new file mode 100644 index 00000000..102a9f04 --- /dev/null +++ b/test/universe/bank/henforcer/bank_test.exs @@ -0,0 +1,33 @@ +defmodule Helix.Universe.Bank.Henforcer.BankTest do + + use Helix.Test.Case.Integration + + import Helix.Test.Henforcer.Macros + + alias Helix.Universe.Bank.Henforcer.Bank, as: BankHenforcer + + alias Helix.Test.Universe.Bank.Helper, as: BankHelper + alias Helix.Test.Universe.Bank.Setup, as: BankSetup + + describe "account_exists?/1" do + test "accepts when account exists" do + bank_acc = BankSetup.account!() + + assert {true, relay} = + BankHenforcer.account_exists?(bank_acc.atm_id, bank_acc.account_number) + + assert relay.bank_account == bank_acc + + assert_relay relay, [:bank_account] + end + + test "rejects when account does not exist" do + assert {false, reason, _} = + BankHenforcer.account_exists?( + BankHelper.atm_id(), BankHelper.account_number() + ) + + assert reason == {:bank_account, :not_found} + end + end +end