diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index fe374806..aa711c30 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -13,7 +13,7 @@ conventions. Please refer to these guidelines! * Write [code](https://en.wikipedia.org/wiki/Computer_programming) in [Elixir](https://elixir-lang.org/) to implement your feature - (Instructions for your computer to do things) +* Use Logger instead of IO.puts/inspect * Commit code using [Angular Commit Message](https://gist.github.com/brianclements/841ea7bffdb01346392c) conventions. diff --git a/config/test.exs b/config/test.exs index 09c6b477..99e67462 100644 --- a/config/test.exs +++ b/config/test.exs @@ -24,7 +24,11 @@ config :epochtalk_server, EpochtalkServerWeb.Endpoint, config :epochtalk_server, EpochtalkServer.Mailer, adapter: Swoosh.Adapters.Test # Print only warnings and errors during test -config :logger, level: :warning +# Accept module and parameters +config :logger, :console, + level: :warning, + format: "[$level] $message $metadata\n", + metadata: [:module, :parameters] # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/epochtalk_server/models/banned_address.ex b/lib/epochtalk_server/models/banned_address.ex index 686667d5..e11b5ffb 100644 --- a/lib/epochtalk_server/models/banned_address.ex +++ b/lib/epochtalk_server/models/banned_address.ex @@ -6,6 +6,13 @@ defmodule EpochtalkServer.Models.BannedAddress do alias EpochtalkServer.Repo alias EpochtalkServer.Models.BannedAddress + @one_week_in_ms 1000 * 60 * 60 * 24 * 7 + @inital_amount 0.8897 + @rate_of_decay 0.9644 + @ip32_weight 1 + @ip24_weight 0.04 + @ip16_weight 0.0016 + @moduledoc """ `BannedAddress` model, for performing actions relating to banning by ip/hostname """ @@ -222,11 +229,13 @@ defmodule EpochtalkServer.Models.BannedAddress do 0 end - ip32_score = calculate_ip32_score(ip) - ip24_score = calculate_ip24_score(ip) - ip16_score = calculate_ip16_score(ip) + # calculate ip scores with weight constants + ip32_score = calculate_ip32_score(ip) * @ip32_weight + ip24_score = calculate_ip24_score(ip) * @ip24_weight + ip16_score = calculate_ip16_score(ip) * @ip16_weight # calculate malicious score using all scores - malicious_score = hostname_score + ip32_score + 0.04 + ip24_score + 0.0016 + ip16_score + malicious_score = hostname_score + ip32_score + ip24_score + ip16_score + if malicious_score < 1, do: nil, else: malicious_score # invalid ip address, return nil for malicious score @@ -310,11 +319,10 @@ defmodule EpochtalkServer.Models.BannedAddress do # in ms since the weight was last updated defp decay_for_time(time, weight) do weight = Decimal.to_float(weight) - one_week = 1000 * 60 * 60 * 24 * 7 - weeks = time / one_week - a = 0.8897 - r = 0.9644 - a ** ((r ** weeks - 1) / (r - 1)) * weight ** (r ** weeks) + weeks = time / @one_week_in_ms + + @inital_amount ** ((@rate_of_decay ** weeks - 1) / (@rate_of_decay - 1)) * + weight ** (@rate_of_decay ** weeks) end # returns the decayed score given a banned address diff --git a/lib/epochtalk_server/models/board_moderator.ex b/lib/epochtalk_server/models/board_moderator.ex index 0abbc836..06c0d4ec 100644 --- a/lib/epochtalk_server/models/board_moderator.ex +++ b/lib/epochtalk_server/models/board_moderator.ex @@ -60,7 +60,7 @@ defmodule EpochtalkServer.Models.BoardModerator do do: Repo.all(from(b in BoardModerator, select: b.board_id, where: b.user_id == ^user_id)) @doc """ - Check if a specific `User` is moderater of a `Board` using a `Board` ID + Check if a specific `User` is moderator of a `Board` using a `Board` ID """ @spec user_is_moderator( board_id :: non_neg_integer, @@ -77,7 +77,7 @@ defmodule EpochtalkServer.Models.BoardModerator do end @doc """ - Check if a specific `User` is moderater of a `Board` using a `Thread` ID + Check if a specific `User` is moderator of a `Board` using a `Thread` ID """ @spec user_is_moderator_with_thread_id(thread_id :: non_neg_integer, user_id :: non_neg_integer) :: boolean @@ -95,7 +95,7 @@ defmodule EpochtalkServer.Models.BoardModerator do end @doc """ - Check if a specific `User` is moderater of a `Board` using a `Post` ID + Check if a specific `User` is moderator of a `Board` using a `Post` ID """ @spec user_is_moderator_with_post_id( post_id :: non_neg_integer, diff --git a/lib/epochtalk_server/session.ex b/lib/epochtalk_server/session.ex index be9079cf..26272e4f 100644 --- a/lib/epochtalk_server/session.ex +++ b/lib/epochtalk_server/session.ex @@ -1,10 +1,14 @@ defmodule EpochtalkServer.Session do @one_day_in_seconds 1 * 24 * 60 * 60 @four_weeks_in_seconds 4 * 7 * @one_day_in_seconds + # in redis, if a key exists and has no expiration, its TTL value is -1 + # if the key does not exist, its TTL value is -2 + @redis_ttl_no_expire_with_key -1 @moduledoc """ Manages `User` sessions in Redis. Used by Auth related `User` actions. """ + require Logger alias EpochtalkServer.Auth.Guardian alias EpochtalkServer.Models.User alias EpochtalkServer.Models.Role @@ -52,30 +56,38 @@ defmodule EpochtalkServer.Session do @doc """ Update session performs the following actions for active session: * Saves `User` session info to redis (avatar, roles, moderating, ban info, etc) - * returns {:ok, user} + * returns {:ok, user} on success + * returns {:error, reason} on failure DOES NOT change: * User's session id, timestamp, ttl * Guardian token """ - @spec update(user_id :: non_neg_integer) :: {:ok, user :: User.t()} + @spec update(user_id :: non_neg_integer) :: + {:ok, user :: User.t()} + | {:error, reason :: String.t() | Postgrex.Error.t()} def update(user_id) do - {:ok, user} = User.by_id(user_id) - avatar = if is_nil(user.profile), do: nil, else: user.profile.avatar - update_user_info(user.id, user.username, avatar: avatar) - update_roles(user.id, user.roles) - - ban_info = - if is_nil(user.ban_info), do: %{}, else: %{ban_expiration: user.ban_info.expiration} - - ban_info = - if !is_nil(user.malicious_score) && user.malicious_score >= 1, - do: Map.put(ban_info, :malicious_score, user.malicious_score), - else: ban_info - - update_ban_info(user.id, ban_info) - update_moderating(user.id, user.moderating) - {:ok, user} + with {:ok, user} <- User.by_id(user_id), + true <- has_sessions?(user_id) do + avatar = if is_nil(user.profile), do: nil, else: user.profile.avatar + update_user_info(user.id, user.username, avatar: avatar) + update_roles(user.id, user.roles) + + ban_info = + if is_nil(user.ban_info), do: %{}, else: %{ban_expiration: user.ban_info.expiration} + + ban_info = + if !is_nil(user.malicious_score) && user.malicious_score >= 1, + do: Map.put(ban_info, :malicious_score, user.malicious_score), + else: ban_info + + update_ban_info(user.id, ban_info) + update_moderating(user.id, user.moderating) + {:ok, user} + else + {:error, error} -> {:error, error} + false -> {:error, :no_sessions} + end end @doc """ @@ -87,7 +99,7 @@ defmodule EpochtalkServer.Session do | {:error, reason :: String.t() | Redix.Error.t() | Redix.ConnectionError.t()} def get_resource(user_id, session_id) do # check if session is active in redis - is_active_for_user_id(session_id, user_id) + is_active_for_user_id?(session_id, user_id) |> case do {:error, error} -> {:error, "Error finding resource from claims #{inspect(error)}"} @@ -122,17 +134,19 @@ defmodule EpochtalkServer.Session do else: resource ban_key = generate_key(user_id, "baninfo") - ban_expiration = Redix.command!(:redix, ["HEXISTS", ban_key, "ban_expiration"]) + ban_expiration_exists = Redix.command!(:redix, ["HEXISTS", ban_key, "ban_expiration"]) + ban_expiration = Redix.command!(:redix, ["HGET", ban_key, "ban_expiration"]) resource = - if ban_expiration != 0, + if ban_expiration_exists != 0, do: Map.put(resource, :ban_expiration, ban_expiration), else: resource - malicious_score = Redix.command!(:redix, ["HEXISTS", ban_key, "malicious_score"]) + malicious_score_exists = Redix.command!(:redix, ["HEXISTS", ban_key, "malicious_score"]) + malicious_score = Redix.command!(:redix, ["HGET", ban_key, "malicious_score"]) resource = - if malicious_score != 0, + if malicious_score_exists != 0, do: Map.put(resource, :malicious_score, malicious_score), else: resource @@ -153,7 +167,19 @@ defmodule EpochtalkServer.Session do Redix.command(:redix, ["ZRANGE", session_key, 0, -1]) end - defp is_active_for_user_id(session_id, user_id) do + defp has_sessions?(user_id) do + session_key = generate_key(user_id, "sessions") + # check ZCARD for key + # returns number of sorted set elements at key + # returns 0 if key does not exist (set is empty) + case Redix.command(:redix, ["ZCARD", session_key]) do + {:error, error} -> {:error, error} + {:ok, 0} -> false + {:ok, _score} -> true + end + end + + defp is_active_for_user_id?(session_id, user_id) do session_key = generate_key(user_id, "sessions") # check ZSCORE for user_id:session_id pair # returns score if it is a member of the set @@ -247,8 +273,10 @@ defmodule EpochtalkServer.Session do moderating = moderating |> Enum.map(& &1.board_id) # save/replace moderating boards to redis under "user:{user_id}:moderating" moderating_key = generate_key(user_id, "moderating") - # get current TTL - {:ok, old_ttl} = Redix.command(:redix, ["TTL", moderating_key]) + # use user_key for base ttl, since moderating may not be populated (TTL == -2) + user_key = generate_key(user_id, "user") + # get current TTL of session + {:ok, session_ttl} = Redix.command(:redix, ["TTL", user_key]) Redix.command(:redix, ["DEL", moderating_key]) unless moderating == [], @@ -257,10 +285,10 @@ defmodule EpochtalkServer.Session do # if ttl is provided if ttl do # maybe extend ttl - maybe_extend_ttl(moderating_key, ttl, old_ttl) + maybe_extend_ttl(moderating_key, ttl, session_ttl) else - # otherwise, re-set old_ttl - maybe_extend_ttl(moderating_key, old_ttl) + # otherwise, re-set session_ttl from user_key + maybe_extend_ttl(moderating_key, session_ttl) end end @@ -292,12 +320,15 @@ defmodule EpochtalkServer.Session do defp update_ban_info(user_id, ban_info, ttl \\ nil) do # save/replace ban_expiration to redis under "user:{user_id}:baninfo" ban_key = generate_key(user_id, "baninfo") - # get current TTL - {:ok, old_ttl} = Redix.command(:redix, ["TTL", ban_key]) + # use user_key for base ttl, since baninfo may not be populated (TTL == -2) + user_key = generate_key(user_id, "user") + # get current TTL of session + {:ok, session_ttl} = Redix.command(:redix, ["TTL", user_key]) Redix.command(:redix, ["HDEL", ban_key, "ban_expiration", "malicious_score"]) - if ban_exp = Map.get(ban_info, :ban_expiration), - do: Redix.command(:redix, ["HSET", ban_key, "ban_expiration", ban_exp]) + if ban_exp = Map.get(ban_info, :ban_expiration) do + Redix.command(:redix, ["HSET", ban_key, "ban_expiration", ban_exp]) + end if malicious_score = Map.get(ban_info, :malicious_score), do: Redix.command(:redix, ["HSET", ban_key, "malicious_score", malicious_score]) @@ -305,10 +336,10 @@ defmodule EpochtalkServer.Session do # if ttl is provided if ttl do # maybe extend ttl - maybe_extend_ttl(ban_key, ttl, old_ttl) + maybe_extend_ttl(ban_key, ttl, session_ttl) else - # otherwise, re-set old_ttl - maybe_extend_ttl(ban_key, old_ttl) + # otherwise, re-set session_ttl from user_key + maybe_extend_ttl(ban_key, session_ttl) end end @@ -353,13 +384,27 @@ defmodule EpochtalkServer.Session do maybe_extend_ttl(key, ttl, ttl) end + # set ttl to max of old_ttl, new_ttl, and existing ttl + # - or @four_weeks_in_seconds if old and new ttl's are invalid defp maybe_extend_ttl(key, new_ttl, old_ttl) do - if old_ttl > -1 do - # re-set old expiry only if old expiry was valid and key has no expiry - Redix.command(:redix, ["EXPIRE", key, old_ttl, "NX"]) - else - # if old expiry was invalid, set new expiry only if key has no expiry - Redix.command(:redix, ["EXPIRE", key, new_ttl, "NX"]) + cond do + old_ttl > @redis_ttl_no_expire_with_key -> + # re-set old expiry only if old expiry was valid and key has no expiry + Redix.command(:redix, ["EXPIRE", key, old_ttl, "NX"]) + + new_ttl > @redis_ttl_no_expire_with_key -> + # if old expiry was invalid, set new expiry only if new expiry is valid and key has no expiry + Redix.command(:redix, ["EXPIRE", key, new_ttl, "NX"]) + + true -> + # catch-all, in case both given expiries are invalid + Logger.warning("Invalid TTL's, setting max expiry", %{ + module: __MODULE__, + parameters: "[old_ttl: #{old_ttl}, new_ttl: #{new_ttl}, key: #{key}]" + }) + + # if neither expiry is valid, set ttl to max + Redix.command(:redix, ["EXPIRE", key, @four_weeks_in_seconds]) end # set expiry only if new expiry is greater than current (GT) diff --git a/lib/epochtalk_server_web/controllers/post.ex b/lib/epochtalk_server_web/controllers/post.ex index bb2db91f..11b9f8ba 100644 --- a/lib/epochtalk_server_web/controllers/post.ex +++ b/lib/epochtalk_server_web/controllers/post.ex @@ -151,7 +151,6 @@ defmodule EpochtalkServerWeb.Controllers.Post do """ # TODO(akinsey): Implement for completion # - parser - # - Implement guard in Validate that prevents passing in page and start at the same time def by_thread(conn, attrs) do # Parameter Validation with thread_id <- Validate.cast(attrs, "thread_id", :integer, required: true), @@ -159,6 +158,7 @@ defmodule EpochtalkServerWeb.Controllers.Post do start <- Validate.cast(attrs, "start", :integer, min: 1), limit <- Validate.cast(attrs, "limit", :integer, default: 25, min: 1, max: 100), desc <- Validate.cast(attrs, "desc", :boolean, default: true), + :ok <- Validate.mutually_exclusive!(attrs, ["page", "start"]), user <- Guardian.Plug.current_resource(conn), user_priority <- ACL.get_user_priority(conn), diff --git a/lib/epochtalk_server_web/controllers/thread.ex b/lib/epochtalk_server_web/controllers/thread.ex index 7e6c0110..be42f8cc 100644 --- a/lib/epochtalk_server_web/controllers/thread.ex +++ b/lib/epochtalk_server_web/controllers/thread.ex @@ -43,9 +43,7 @@ defmodule EpochtalkServerWeb.Controllers.Thread do # TODO(akinsey): Post pre processing, authorizations, image processing, hooks. Things to consider: # - does html sanitizer need to run on the front end too # - how do we run the same parser/sanitizer on the elixir back end as the node frontend - # - processing mentions # - does createImageReferences need to be called here? was this missing from the old code? - # - does updateUserActivity need to be called here? was this missing from the old code? def create(conn, attrs) do # authorization checks with :ok <- ACL.allow!(conn, "threads.create"), diff --git a/lib/epochtalk_server_web/errors/custom_errors.ex b/lib/epochtalk_server_web/errors/custom_errors.ex index 6ae8d6d1..6c3f5f84 100644 --- a/lib/epochtalk_server_web/errors/custom_errors.ex +++ b/lib/epochtalk_server_web/errors/custom_errors.ex @@ -49,6 +49,7 @@ defmodule EpochtalkServerWeb.CustomErrors do def exception(value) do case value do [] -> %InvalidPayload{} + [message: message] -> %InvalidPayload{message: message} _ -> %InvalidPayload{message: gen_message(Enum.into(value, %{required: false}))} end end diff --git a/lib/epochtalk_server_web/helpers/validate.ex b/lib/epochtalk_server_web/helpers/validate.ex index a9ee6f02..e9ecfb5e 100644 --- a/lib/epochtalk_server_web/helpers/validate.ex +++ b/lib/epochtalk_server_web/helpers/validate.ex @@ -6,6 +6,20 @@ defmodule EpochtalkServerWeb.Helpers.Validate do """ alias EpochtalkServerWeb.CustomErrors.InvalidPayload + @doc """ + Ensure that `keys` provided in list are mutually exclusive within `attrs` map. + """ + @spec mutually_exclusive!(attrs :: map, keys :: [String.t()]) :: :ok | no_return + def mutually_exclusive!(attrs, keys) when is_map(attrs) and is_list(keys) do + if map_contains_any_two_keys_in_list?(attrs, keys), + do: + raise(InvalidPayload, + message: + "The following payload parameters cannot be passed at the same time: #{Enum.join(keys, ", ")}" + ), + else: :ok + end + @doc """ Helper used to validate and cast request parameters directly out of the incoming paylod map (usually a controller function's `attrs` parameter) to the specified type. @@ -121,6 +135,29 @@ defmodule EpochtalkServerWeb.Helpers.Validate do end end + # entrypoint + defp map_contains_any_two_keys_in_list?(map, list), + do: map_contains_any_two_keys_in_list?(map, list, false) + + # if map is empty, return false + defp map_contains_any_two_keys_in_list?(map, _list, _key_found?) when map == %{}, do: false + # if list is empty, return false + defp map_contains_any_two_keys_in_list?(_map, [], _key_found?), do: false + # if key_found? is false + defp map_contains_any_two_keys_in_list?(map, [key | keys] = _list, false = _key_found?) do + # check next key with updated key_found? + map_contains_any_two_keys_in_list?(map, keys, Map.has_key?(map, key)) + end + + # if key_found? is true + defp map_contains_any_two_keys_in_list?(map, [key | keys] = _list, true = key_found?) do + # if current key is in map, return true + if Map.has_key?(map, key), + do: true, + # otherwise, check next key + else: map_contains_any_two_keys_in_list?(map, keys, key_found?) + end + defp to_bool(str, opts) do case str do "true" -> true diff --git a/test/epochtalk_server/models/profile_test.exs b/test/epochtalk_server/models/profile_test.exs new file mode 100644 index 00000000..8f0c3e88 --- /dev/null +++ b/test/epochtalk_server/models/profile_test.exs @@ -0,0 +1,52 @@ +defmodule Test.EpochtalkServer.Models.Profile do + use Test.Support.DataCase, async: true + alias EpochtalkServer.Models.Profile + alias EpochtalkServer.Models.User + + describe "upsert/2" do + test "given valid user_id, updates user's profile", %{users: %{user: user}} do + # check default fields + assert user.profile.avatar == "" + assert user.profile.position == nil + assert user.profile.signature == nil + assert user.profile.post_count == 0 + assert user.profile.fields == nil + assert user.profile.raw_signature == nil + assert user.profile.last_active == nil + + updated_attrs = %{ + avatar: "image.png", + position: "position", + signature: "signature", + raw_signature: "raw_signature" + } + + Profile.upsert(user.id, updated_attrs) + {:ok, updated_user} = User.by_id(user.id) + assert updated_user.profile.avatar == updated_attrs.avatar + assert updated_user.profile.position == updated_attrs.position + assert updated_user.profile.signature == updated_attrs.signature + assert updated_user.profile.raw_signature == updated_attrs.raw_signature + end + end + + describe "create/2" do + test "given invalid user_id, errors with Ecto.ConstraintError" do + invalid_user_id = 0 + + assert_raise Ecto.ConstraintError, + ~r/profiles_user_id_fkey/, + fn -> + Profile.create(invalid_user_id) + end + end + + test "given an existing user_id, errors with Ecto.ConstraintError", %{users: %{user: user}} do + assert_raise Ecto.ConstraintError, + ~r/profiles_user_id_index/, + fn -> + Profile.create(user.id) + end + end + end +end diff --git a/test/epochtalk_server/session_test.exs b/test/epochtalk_server/session_test.exs index 1632e0cb..48130c9a 100644 --- a/test/epochtalk_server/session_test.exs +++ b/test/epochtalk_server/session_test.exs @@ -4,11 +4,19 @@ defmodule Test.EpochtalkServer.Session do run into concurrency issues when run alongside other tests """ use Test.Support.ConnCase, async: false + import Test.Support.Factory @one_day_in_seconds 1 * 24 * 60 * 60 @almost_one_day_in_seconds @one_day_in_seconds - 100 @four_weeks_in_seconds 4 * 7 * @one_day_in_seconds @almost_four_weeks_in_seconds @four_weeks_in_seconds - 100 + @max_date "9999-12-31 00:00:00" alias EpochtalkServer.Session + alias EpochtalkServer.Models.Profile + alias EpochtalkServer.Models.User + alias EpochtalkServer.Models.RoleUser + alias EpochtalkServer.Cache.Role, as: RoleCache + alias EpochtalkServer.Models.Ban + alias EpochtalkServer.Models.BoardModerator describe "get_resource/2" do test "when session_id is invalid, errors", %{users: %{user: user}} do @@ -267,7 +275,7 @@ defmodule Test.EpochtalkServer.Session do Redix.command!(:redix, ["HGET", "user:#{user.id}:baninfo", "ban_expiration"]) assert pre_ban_ban_expiration == nil - assert ban_expiration == "9999-12-31 00:00:00" + assert ban_expiration == @max_date assert pre_ban_baninfo_ttl == -2 assert baninfo_ttl <= @one_day_in_seconds assert baninfo_ttl > @almost_one_day_in_seconds @@ -299,7 +307,7 @@ defmodule Test.EpochtalkServer.Session do Redix.command!(:redix, ["HGET", "user:#{user.id}:baninfo", "ban_expiration"]) assert pre_ban_ban_expiration == nil - assert ban_expiration == "9999-12-31 00:00:00" + assert ban_expiration == @max_date assert pre_ban_baninfo_ttl == -2 assert baninfo_ttl <= @four_weeks_in_seconds assert baninfo_ttl > @almost_four_weeks_in_seconds @@ -326,7 +334,7 @@ defmodule Test.EpochtalkServer.Session do Redix.command!(:redix, ["HGET", "user:#{authed_user.id}:baninfo", "malicious_score"]) assert pre_malicious_malicious_score == nil - assert malicious_score == "4.0416" + assert malicious_score == "2.0416" assert pre_malicious_baninfo_ttl == -2 assert malicious_score_ttl <= @one_day_in_seconds assert malicious_score_ttl > @almost_one_day_in_seconds @@ -353,13 +361,183 @@ defmodule Test.EpochtalkServer.Session do Redix.command!(:redix, ["HGET", "user:#{authed_user.id}:baninfo", "malicious_score"]) assert pre_malicious_malicious_score == nil - assert malicious_score == "4.0416" + assert malicious_score == "2.0416" assert pre_malicious_baninfo_ttl == -2 assert malicious_score_ttl <= @four_weeks_in_seconds assert malicious_score_ttl > @almost_four_weeks_in_seconds end end + describe "update/1" do + test "given invalid user id, errors with :user_not_found" do + assert Session.update(0) == {:error, :user_not_found} + end + + test "given user id without sessions, errors with :no_sessions", %{ + users: %{no_login_user: user} + } do + assert Session.update(user.id) == {:error, :no_sessions} + end + + @tag :authenticated + test "given a valid user id, updates avatar", %{conn: conn, authed_user: authed_user} do + updated_attrs = %{avatar: "image.png"} + Profile.upsert(authed_user.id, updated_attrs) + Session.update(authed_user.id) + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + assert resource_user.avatar == updated_attrs.avatar + end + end + + describe "update/1 role" do + @tag :authenticated + test "given a valid user id, updates role to admin", %{conn: conn, authed_user: authed_user} do + authed_user_role = List.first(authed_user.roles) + assert authed_user_role == RoleCache.by_lookup("user") + RoleUser.set_admin(authed_user.id) + Session.update(authed_user.id) + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + resource_user_role = List.first(resource_user.roles) + assert resource_user_role == RoleCache.by_lookup("superAdministrator") + end + + @tag authenticated: :super_admin + test "given a valid superAdministrator id, updates role to delete admin + add user", %{ + conn: conn, + authed_user: authed_user + } do + authed_user_role = List.first(authed_user.roles) + super_administrator_role = RoleCache.by_lookup("superAdministrator") + assert authed_user_role == super_administrator_role + RoleUser.delete(super_administrator_role.id, authed_user.id) + Session.update(authed_user.id) + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + resource_user_role = List.first(resource_user.roles) + user_role = RoleCache.by_lookup("user") + assert resource_user_role == user_role + end + end + + describe "update/1 baninfo" do + @tag :authenticated + test "given a not banned user's id, when user is banned, adds banned role", %{ + conn: conn, + authed_user: authed_user + } do + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + # check that session user does not have banned role + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(resource_user.roles, &(&1.lookup == "banned")) == false + + # ban user + Ban.ban(authed_user) + + # check that session user has banned role + Session.update(authed_user.id) + {:ok, banned_resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(banned_resource_user.roles, &(&1.lookup == "banned")) == true + # check ban is active + assert banned_resource_user.ban_expiration == @max_date + end + + @tag [authenticated: :banned, banned: true] + test "given a banned user's id, when user is unbanned, deletes banned role", %{ + conn: conn, + authed_user: authed_user + } do + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + # check that session user has banned role + {:ok, banned_resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(banned_resource_user.roles, &(&1.lookup == "banned")) == true + + # unban user + Ban.unban_by_user_id(authed_user.id) + + # check that session user does not have banned role + Session.update(authed_user.id) + {:ok, unbanned_resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(unbanned_resource_user.roles, &(&1.lookup == "banned")) == false + # check ban is expired + now = NaiveDateTime.utc_now() + + {:ok, ban_expiration} = + unbanned_resource_user.ban_expiration |> NaiveDateTime.from_iso8601() + + assert NaiveDateTime.compare(ban_expiration, now) == :lt + end + + @tag :authenticated + test "given a not malicious user's id, when user is marked malicious, adds malicious score", + %{ + conn: conn, + authed_user: authed_user + } do + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + # check that session user does not have banned role + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(resource_user.roles, &(&1.lookup == "banned")) == false + # check user does not have malicious score or ban expiration + assert Map.get(resource_user, :malicious_score) == nil + assert Map.get(resource_user, :ban_expiration) == nil + + # update user malicious + build(:banned_address, ip: "127.0.0.1", weight: 1.0) + build(:banned_address, hostname: "localhost", weight: 1.0) + User.handle_malicious_user(authed_user, conn.remote_ip) + + # check that session user has banned role + Session.update(authed_user.id) + {:ok, malicious_resource_user} = Session.get_resource(authed_user.id, session_id) + assert Enum.any?(malicious_resource_user.roles, &(&1.lookup == "banned")) == true + # check malicious score and ban are active + assert malicious_resource_user.malicious_score != nil + assert malicious_resource_user.malicious_score > 1 + assert malicious_resource_user.ban_expiration == @max_date + end + end + + describe "update/1 moderating" do + @tag :authenticated + test "when a user is added/removed as moderator, updates moderating", %{ + conn: conn, + authed_user: authed_user + } do + # get session_id (jti) from conn + session_id = conn.private.guardian_default_claims["jti"] + # check that session user's moderating list is empty + {:ok, resource_user} = Session.get_resource(authed_user.id, session_id) + assert Map.get(resource_user, :moderating) == nil + + # create board and add user as moderator + board = insert(:board) + BoardModerator.add_moderators_by_username(board.id, [authed_user.username]) + + # check session user updates with moderating + Session.update(authed_user.id) + {:ok, moderator_resource_user} = Session.get_resource(authed_user.id, session_id) + # check moderating list contains new board + assert Map.get(moderator_resource_user, :moderating) == [Integer.to_string(board.id)] + + # remove user as moderator + BoardModerator.remove_moderators_by_username(board.id, [authed_user.username]) + + # check session user updates with moderating + Session.update(authed_user.id) + {:ok, moderator_resource_user} = Session.get_resource(authed_user.id, session_id) + # check moderating list contains new board + assert Map.get(moderator_resource_user, :moderating) == nil + end + end + defp flush_redis(_) do Redix.command!(:redix, ["FLUSHALL"]) :ok diff --git a/test/epochtalk_server_web/controllers/board_test.exs b/test/epochtalk_server_web/controllers/board_test.exs index 66a8878f..7e403c06 100644 --- a/test/epochtalk_server_web/controllers/board_test.exs +++ b/test/epochtalk_server_web/controllers/board_test.exs @@ -30,6 +30,17 @@ defmodule Test.EpochtalkServerWeb.Controllers.Board do end describe "by_category/2" do + @tag authenticated: :private + test "when authenticated with invalid permissions, raises InvalidPermission error", %{ + conn: conn + } do + assert_raise InvalidPermission, + ~r/^Forbidden, invalid permissions to perform this action/, + fn -> + get(conn, Routes.board_path(conn, :by_category)) + end + end + test "finds all active boards", %{ conn: conn, category: category, @@ -130,7 +141,23 @@ defmodule Test.EpochtalkServerWeb.Controllers.Board do assert response["message"] == "Error, board does not exist" end - test "given an existing id, finds a board", %{conn: conn, parent_board: board} do + test "when unauthenticated, given an existing id above read access, errors", %{ + conn: conn, + admin_board: admin_board + } do + response = + conn + |> get(Routes.board_path(conn, :find, admin_board.id)) + |> json_response(404) + + assert response["error"] == "Not Found" + assert response["message"] == "Board not found" + end + + test "when unauthenticated, given an existing id within read access, finds a board", %{ + conn: conn, + parent_board: board + } do response = conn |> get(Routes.board_path(conn, :find, board.id)) @@ -146,6 +173,73 @@ defmodule Test.EpochtalkServerWeb.Controllers.Board do assert response["disable_post_edit"] == board.meta["disable_post_edit"] assert response["disable_signature"] == board.meta["disable_signature"] end + + @tag :authenticated + test "when authenticated, given an existing id above read access, errors", %{ + conn: conn, + admin_board: admin_board + } do + response = + conn + |> get(Routes.board_path(conn, :find, admin_board.id)) + |> json_response(404) + + assert response["error"] == "Not Found" + assert response["message"] == "Board not found" + end + + @tag :authenticated + test "when authenticated, given an existing id at read access, finds board", %{ + conn: conn, + parent_board: board + } do + response = + conn + |> get(Routes.board_path(conn, :find, board.id)) + |> json_response(200) + + assert response["name"] == board.name + end + + @tag authenticated: :admin + test "when authenticated as admin, given an existing id at read access, finds board", %{ + conn: conn, + admin_board: admin_board + } do + response = + conn + |> get(Routes.board_path(conn, :find, admin_board.id)) + |> json_response(200) + + assert response["name"] == admin_board.name + end + + @tag authenticated: :admin + test "when authenticated as admin, given an existing id above read access, errors", %{ + conn: conn, + super_admin_board: super_admin_board + } do + response = + conn + |> get(Routes.board_path(conn, :find, super_admin_board.id)) + |> json_response(404) + + assert response["error"] == "Not Found" + assert response["message"] == "Board not found" + end + + @tag authenticated: :super_admin + test "when authenticated as super admin, given an existing id at read access, finds board", %{ + conn: conn, + super_admin_board: super_admin_board + } do + response = + conn + |> get(Routes.board_path(conn, :find, super_admin_board.id)) + |> json_response(200) + + assert response["name"] == super_admin_board.name + end end describe "slug_to_id/2" do diff --git a/test/epochtalk_server_web/controllers/user_test.exs b/test/epochtalk_server_web/controllers/user_test.exs index c7eafd78..f3327b06 100644 --- a/test/epochtalk_server_web/controllers/user_test.exs +++ b/test/epochtalk_server_web/controllers/user_test.exs @@ -72,7 +72,7 @@ defmodule Test.EpochtalkServerWeb.Controllers.User do } do assert malicious_user_changeset.ban_info.user_id == user.id # check that ip and hostname were banned - assert malicious_user_changeset.malicious_score == 4.0416 + assert malicious_user_changeset.malicious_score == 2.0416 end end diff --git a/test/epochtalk_server_web/helpers/validate_test.exs b/test/epochtalk_server_web/helpers/validate_test.exs index 6276141f..cf02f7ee 100644 --- a/test/epochtalk_server_web/helpers/validate_test.exs +++ b/test/epochtalk_server_web/helpers/validate_test.exs @@ -1,5 +1,56 @@ defmodule Test.EpochtalkServerWeb.Helpers.Validate do use Test.Support.ConnCase, async: true alias EpochtalkServerWeb.Helpers.Validate + alias EpochtalkServerWeb.CustomErrors.InvalidPayload doctest Validate + + describe "mutually_exclusive/2" do + test "given cases, returns correct result" do + cases = [ + %{ + attrs: %{"page" => 1}, + keys: ["page", "start"], + result: :ok + }, + %{ + attrs: %{"start" => 1}, + keys: ["page", "start"], + result: :ok + }, + %{ + attrs: %{"start" => 1}, + keys: ["start"], + result: :ok + }, + %{ + attrs: %{"page" => 1, "start" => 1}, + keys: ["page", "start"], + result: :error + }, + %{ + attrs: %{"page" => 1, "start" => 1, "stop" => 1}, + keys: ["page", "start"], + result: :error + }, + %{ + attrs: %{"page" => 1, "stop" => 1}, + keys: ["page", "start", "stop", "enter"], + result: :error + } + ] + + cases + |> Enum.each(fn + %{attrs: attrs, keys: keys, result: :error} -> + assert_raise InvalidPayload, + ~r/^The following payload parameters cannot be passed at the same time:/, + fn -> + Validate.mutually_exclusive!(attrs, keys) + end + + %{attrs: attrs, keys: keys, result: :ok} -> + assert Validate.mutually_exclusive!(attrs, keys) == :ok + end) + end + end end diff --git a/test/seed/users.exs b/test/seed/users.exs index 413539ad..a37015de 100644 --- a/test/seed/users.exs +++ b/test/seed/users.exs @@ -40,6 +40,10 @@ test_private_user_username = "private" test_private_user_email = "private@test.com" test_private_user_password = "password" +test_no_login_user_username = "no_login" +test_no_login_user_email = "no_login@test.com" +test_no_login_user_password = "password" + build(:user, username: test_super_admin_user_username, email: test_super_admin_user_email, @@ -110,4 +114,10 @@ build(:user, ) |> with_role_id(10) +build(:user, + username: test_no_login_user_username, + email: test_no_login_user_email, + password: test_no_login_user_password +) + IO.puts("Successfully seeded test users") diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index db982e3b..7afb0368 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -115,6 +115,16 @@ defmodule Test.Support.ConnCase do password: @test_private_password } + # no_login username/email/password from user seed in `mix test` (see mix.exs) + @test_no_login_username "no_login" + @test_no_login_email "no_login@test.com" + @test_no_login_password "password" + @test_no_login_user_attrs %{ + username: @test_no_login_username, + email: @test_no_login_email, + password: @test_no_login_password + } + use ExUnit.CaseTemplate using do @@ -155,6 +165,7 @@ defmodule Test.Support.ConnCase do {:ok, banned_user} = User.by_username(@test_banned_username) {:ok, anonymous_user} = User.by_username(@test_anonymous_username) {:ok, private_user} = User.by_username(@test_private_username) + {:ok, no_login_user} = User.by_username(@test_no_login_username) conn = Phoenix.ConnTest.build_conn() @@ -171,7 +182,8 @@ defmodule Test.Support.ConnCase do newbie_user: newbie_user, banned_user: banned_user, anonymous_user: anonymous_user, - private_user: private_user + private_user: private_user, + no_login_user: no_login_user }, user_attrs: %{ super_admin_user: @test_super_admin_user_attrs, @@ -183,7 +195,8 @@ defmodule Test.Support.ConnCase do newbie_user: @test_newbie_user_attrs, banned_user: @test_banned_user_attrs, anonymous_user: @test_anonymous_user_attrs, - private_user: @test_private_user_attrs + private_user: @test_private_user_attrs, + no_login_user: @test_no_login_user_attrs } ]} @@ -322,6 +335,8 @@ defmodule Test.Support.ConnCase do context_updates = if context[:banned] do {:ok, banned_user_changeset} = Ban.ban(banned_user) + # update ban info in session + Session.update(banned_user.id) {:ok, k_list} = context_updates {:ok, [banned_user_changeset: banned_user_changeset] ++ k_list} else diff --git a/test/support/data_case.ex b/test/support/data_case.ex index c7486c74..9695629e 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -14,6 +14,46 @@ defmodule Test.Support.DataCase do this option is not recommended for other databases. """ + # no_login username/email/password from user seed in `mix test` (see mix.exs) + @test_no_login_username "no_login" + @test_no_login_email "no_login@test.com" + @test_no_login_password "password" + @test_no_login_user_attrs %{ + username: @test_no_login_username, + email: @test_no_login_email, + password: @test_no_login_password + } + + # username/email/password from user seed in `mix test` (see mix.exs) + @test_username "user" + @test_email "user@test.com" + @test_password "password" + @test_user_attrs %{ + username: @test_username, + email: @test_email, + password: @test_password + } + + # admin username/email/password from user seed in `mix test` (see mix.exs) + @test_admin_username "admin" + @test_admin_email "admin@test.com" + @test_admin_password "password" + @test_admin_user_attrs %{ + username: @test_admin_username, + email: @test_admin_email, + password: @test_admin_password + } + + # super admin username/email/password from user seed in `mix test` (see mix.exs) + @test_super_admin_username "superadmin" + @test_super_admin_email "superadmin@test.com" + @test_super_admin_password "password" + @test_super_admin_user_attrs %{ + username: @test_super_admin_username, + email: @test_super_admin_email, + password: @test_super_admin_password + } + use ExUnit.CaseTemplate using do @@ -28,8 +68,30 @@ defmodule Test.Support.DataCase do end setup tags do + alias EpochtalkServer.Models.User Test.Support.DataCase.setup_sandbox(tags) - :ok + {:ok, no_login_user} = User.by_username(@test_no_login_username) + {:ok, user} = User.by_username(@test_username) + {:ok, admin_user} = User.by_username(@test_admin_username) + {:ok, super_admin_user} = User.by_username(@test_super_admin_username) + + { + :ok, + [ + users: %{ + no_login_user: no_login_user, + user: user, + admin_user: admin_user, + super_admin_user: super_admin_user + }, + user_attrs: %{ + no_login_user: @test_no_login_user_attrs, + user: @test_user_attrs, + admin_user: @test_admin_user_attrs, + super_admin_user: @test_super_admin_user_attrs + } + ] + } end @doc """ diff --git a/test/support/factories/banned_address.ex b/test/support/factories/banned_address.ex index 75247ab4..beea7350 100644 --- a/test/support/factories/banned_address.ex +++ b/test/support/factories/banned_address.ex @@ -7,34 +7,11 @@ defmodule Test.Support.Factories.BannedAddress do OR build(:banned_address, hostname: hostname, weight: weight) """ - alias EpochtalkServer.Repo alias EpochtalkServer.Models.BannedAddress defmacro __using__(_opts) do quote do - def banned_address_attributes_factory(%{ip: ip, weight: weight}) do - %{ - ip: ip, - weight: weight, - created_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), - imported_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) - } - end - - def banned_address_attributes_factory(%{hostname: hostname, weight: weight}) do - %{ - hostname: hostname, - weight: weight, - created_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), - imported_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) - } - end - - def banned_address_factory(attributes) do - %BannedAddress{} - |> BannedAddress.upsert_changeset(build(:banned_address_attributes, attributes)) - |> Repo.insert() - end + def banned_address_factory(attributes), do: BannedAddress.upsert(attributes) end end end