diff --git a/config/config.exs b/config/config.exs index b8cf59b..8ab193c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,6 +22,18 @@ config :nulla, NullaWeb.Endpoint, pubsub_server: Nulla.PubSub, live_view: [signing_salt: "rmaJ4fGm"] +# Snowflake configuration +config :nulla, :snowflake, worker_id: 1 + +# Instance configuration +config :nulla, :instance, + name: "Nulla", + description: "Freedom Social Network", + registration: false, + max_characters: 5000, + max_upload_size: 50, + api_limit: 100 + # Configures the mailer # # By default it uses the "Local" adapter which stores the emails diff --git a/config/test.exs b/config/test.exs index c0f45e2..8c70f7f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/lib/nulla/accounts.ex b/lib/nulla/accounts.ex new file mode 100644 index 0000000..b6e5d8b --- /dev/null +++ b/lib/nulla/accounts.ex @@ -0,0 +1,377 @@ +defmodule Nulla.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs, hash_password: false, validate_email: false) + end + + ## Settings + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}) do + User.email_changeset(user, attrs, validate_email: false) + end + + @doc """ + Emulates that the email will change without actually changing + it in the database. + + ## Examples + + iex> apply_user_email(user, "valid password", %{email: ...}) + {:ok, %User{}} + + iex> apply_user_email(user, "invalid password", %{email: ...}) + {:error, %Ecto.Changeset{}} + + """ + def apply_user_email(user, password, attrs) do + user + |> User.email_changeset(attrs) + |> User.validate_current_password(password) + |> Ecto.Changeset.apply_action(:update) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + The confirmed_at date is also updated to the current time. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do + :ok + else + _ -> :error + end + end + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}) do + User.password_changeset(user, attrs, hash_password: false) + end + + @doc """ + Updates the user password. + + ## Examples + + iex> update_user_password(user, "valid password", %{password: ...}) + {:ok, %User{}} + + iex> update_user_password(user, "invalid password", %{password: ...}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, password, attrs) do + changeset = + user + |> User.password_changeset(attrs) + |> User.validate_current_password(password) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, changeset) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) + :ok + end + + ## Confirmation + + @doc ~S""" + Delivers the confirmation email instructions to the given user. + + ## Examples + + iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) + {:ok, %{to: ..., body: ...}} + + iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) + {:error, :already_confirmed} + + """ + def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) + when is_function(confirmation_url_fun, 1) do + if user.confirmed_at do + {:error, :already_confirmed} + else + {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") + Repo.insert!(user_token) + UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + end + end + + @doc """ + Confirms a user by the given token. + + If the token matches, the user account is marked as confirmed + and the token is deleted. + """ + def confirm_user(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), + %User{} = user <- Repo.one(query), + {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do + {:ok, user} + else + _ -> :error + end + end + + defp confirm_user_multi(user) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) + end + + ## Reset password + + @doc ~S""" + Delivers the reset password email to the given user. + + ## Examples + + iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) + when is_function(reset_password_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") + Repo.insert!(user_token) + UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + end + + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Resets the user password. + + ## Examples + + iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + def reset_user_password(user, attrs) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) + |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> {:ok, user} + {:error, :user, changeset, _} -> {:error, changeset} + end + end + + @doc """ + Creates a new api token for a user. + + The token returned must be saved somewhere safe. + This token cannot be recovered from the database. + """ + def create_user_api_token(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") + Repo.insert!(user_token) + encoded_token + end + + @doc """ + Fetches the user by API token. + """ + def fetch_user_by_api_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "api-token"), + %User{} = user <- Repo.one(query) do + {:ok, user} + else + _ -> :error + end + end +end diff --git a/lib/nulla/accounts/user.ex b/lib/nulla/accounts/user.ex new file mode 100644 index 0000000..cace737 --- /dev/null +++ b/lib/nulla/accounts/user.ex @@ -0,0 +1,171 @@ +defmodule Nulla.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :current_password, :string, virtual: true, redact: true + field :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + + * `:validate_email` - Validates the uniqueness of the email, in case + you don't want to validate the uniqueness of the email (like when + using this changeset for validations on a LiveView form before + submitting the form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email, :password]) + |> maybe_put_id() + |> validate_email(opts) + |> validate_password(opts) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + defp maybe_validate_unique_email(changeset, opts) do + if Keyword.get(opts, :validate_email, true) do + changeset + |> unsafe_validate_unique(:email, Nulla.Repo) + |> unique_constraint(:email) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. + + It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Nulla.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + changeset = cast(changeset, %{current_password: password}, [:current_password]) + + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end diff --git a/lib/nulla/accounts/user_notifier.ex b/lib/nulla/accounts/user_notifier.ex new file mode 100644 index 0000000..682f899 --- /dev/null +++ b/lib/nulla/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Nulla.Accounts.UserNotifier do + import Swoosh.Email + + alias Nulla.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Nulla", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/nulla/accounts/user_token.ex b/lib/nulla/accounts/user_token.ex new file mode 100644 index 0000000..d351435 --- /dev/null +++ b/lib/nulla/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule Nulla.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Nulla.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, Nulla.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in by_token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def by_user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def by_user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/nulla/activities.ex b/lib/nulla/activities.ex new file mode 100644 index 0000000..c6119fd --- /dev/null +++ b/lib/nulla/activities.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Activities do + @moduledoc """ + The Activities context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Activities.Activity + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + Repo.all(Activity) + end + + @doc """ + Gets a single activity. + + Raises `Ecto.NoResultsError` if the Activity does not exist. + + ## Examples + + iex> get_activity!(123) + %Activity{} + + iex> get_activity!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity!(id), do: Repo.get!(Activity, id) + + @doc """ + Creates a activity. + + ## Examples + + iex> create_activity(%{field: value}) + {:ok, %Activity{}} + + iex> create_activity(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity(attrs \\ %{}) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity. + + ## Examples + + iex> update_activity(activity, %{field: new_value}) + {:ok, %Activity{}} + + iex> update_activity(activity, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity(%Activity{} = activity, attrs) do + activity + |> Activity.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity. + + ## Examples + + iex> delete_activity(activity) + {:ok, %Activity{}} + + iex> delete_activity(activity) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity(%Activity{} = activity) do + Repo.delete(activity) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity changes. + + ## Examples + + iex> change_activity(activity) + %Ecto.Changeset{data: %Activity{}} + + """ + def change_activity(%Activity{} = activity, attrs \\ %{}) do + Activity.changeset(activity, attrs) + end +end diff --git a/lib/nulla/activities/activity.ex b/lib/nulla/activities/activity.ex new file mode 100644 index 0000000..23a35b7 --- /dev/null +++ b/lib/nulla/activities/activity.ex @@ -0,0 +1,34 @@ +defmodule Nulla.Activities.Activity do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Types.StringOrJson + + @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} + @primary_key {:id, :integer, autogenerate: false} + schema "activities" do + field :ap_id, :string + field :type, :string + field :actor, :string + field :object, StringOrJson + field :to, {:array, :string} + field :cc, {:array, :string} + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:ap_id, :type, :actor, :object, :to, :cc]) + |> maybe_put_id() + |> validate_required([:ap_id, :type, :actor, :object]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/actors.ex b/lib/nulla/actors.ex new file mode 100644 index 0000000..9064c46 --- /dev/null +++ b/lib/nulla/actors.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Actors do + @moduledoc """ + The Actors context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Actors.Actor + + @doc """ + Returns the list of actors. + + ## Examples + + iex> list_actors() + [%Actor{}, ...] + + """ + def list_actors do + Repo.all(Actor) + end + + @doc """ + Gets a single actor. + + Raises `Ecto.NoResultsError` if the Actor does not exist. + + ## Examples + + iex> get_actor!(123) + %Actor{} + + iex> get_actor!(456) + ** (Ecto.NoResultsError) + + """ + def get_actor!(id), do: Repo.get!(Actor, id) + + @doc """ + Creates a actor. + + ## Examples + + iex> create_actor(%{field: value}) + {:ok, %Actor{}} + + iex> create_actor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_actor(attrs \\ %{}) do + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a actor. + + ## Examples + + iex> update_actor(actor, %{field: new_value}) + {:ok, %Actor{}} + + iex> update_actor(actor, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_actor(%Actor{} = actor, attrs) do + actor + |> Actor.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a actor. + + ## Examples + + iex> delete_actor(actor) + {:ok, %Actor{}} + + iex> delete_actor(actor) + {:error, %Ecto.Changeset{}} + + """ + def delete_actor(%Actor{} = actor) do + Repo.delete(actor) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking actor changes. + + ## Examples + + iex> change_actor(actor) + %Ecto.Changeset{data: %Actor{}} + + """ + def change_actor(%Actor{} = actor, attrs \\ %{}) do + Actor.changeset(actor, attrs) + end +end diff --git a/lib/nulla/actors/actor.ex b/lib/nulla/actors/actor.ex new file mode 100644 index 0000000..67a71d1 --- /dev/null +++ b/lib/nulla/actors/actor.ex @@ -0,0 +1,95 @@ +defmodule Nulla.Actors.Actor do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "actors" do + field :acct, :string + field :ap_id, :string + field :type, :string + field :following, :string + field :followers, :string + field :inbox, :string + field :outbox, :string + field :featured, :string + field :featuredTags, :string + field :preferredUsername, :string + field :name, :string + field :summary, :string + field :url, :string + field :manuallyApprovesFollowers, :boolean, default: false + field :discoverable, :boolean, default: true + field :indexable, :boolean, default: true + field :published, :utc_datetime + field :memorial, :boolean, default: false + field :publicKey, :map + field :privateKeyPem, :string + field :tag, {:array, :map}, default: [] + field :attachment, {:array, :map}, default: [] + field :endpoints, :map + field :icon, :map + field :image, :map + field :vcard_bday, :date + field :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :privateKeyPem, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + |> maybe_put_id() + |> validate_required([ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :url, + :publicKey, + :endpoints + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex index 7e86858..6832652 100644 --- a/lib/nulla/application.ex +++ b/lib/nulla/application.ex @@ -7,6 +7,8 @@ defmodule Nulla.Application do @impl true def start(_type, _args) do + worker_id = Application.fetch_env!(:nulla, :snowflake)[:worker_id] + children = [ NullaWeb.Telemetry, Nulla.Repo, @@ -17,7 +19,8 @@ defmodule Nulla.Application do # Start a worker by calling: Nulla.Worker.start_link(arg) # {Nulla.Worker, arg}, # Start to serve requests, typically the last entry - NullaWeb.Endpoint + NullaWeb.Endpoint, + {Nulla.Snowflake, worker_id} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex new file mode 100644 index 0000000..35c3977 --- /dev/null +++ b/lib/nulla/http_signature.ex @@ -0,0 +1,111 @@ +defmodule Nulla.HTTPSignature do + import Plug.Conn + + def make_headers(body, inbox_url, publicKeyId, privateKeyPem) do + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + uri = URI.parse(inbox_url) + + signature_string = + "(request-target): post #{uri.path}\n" <> + "host: #{uri.host}\n" <> + "date: #{date}\n" <> + "digest: #{digest}" + + private_key = + case :public_key.pem_decode(privateKeyPem) do + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{publicKeyId}", + algorithm="rsa-sha256", + headers="(request-target) host date digest", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"Digest", digest}, + {"Signature", signature_header} + ] + end + + def verify(conn, public_key_pem) do + with [sig_header] <- get_req_header(conn, "signature"), + signature_map <- parse_signature_header(sig_header), + {:ok, signed_string} <- build_signature_string(signature_map["headers"], conn), + true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do + :ok + else + _ -> {:error, :invalid_signature} + end + end + + defp parse_signature_header(header) do + header = + header + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {String.trim(k), String.trim(v, ~s("))} + end) + |> Enum.into(%{}) + + header + end + + defp build_signature_string(nil, _conn), do: {:error, :missing_headers} + + defp build_signature_string(headers_str, conn) do + headers = String.split(headers_str, " ") + + result = + Enum.map(headers, fn header -> + line = + case header do + "(request-target)" -> + method = String.downcase(conn.method) + + path = + conn.request_path <> + if conn.query_string != "", do: "?" <> conn.query_string, else: "" + + "(request-target): #{method} #{path}" + + "host" -> + "host: #{conn.host}" + + _ -> + value = get_req_header(conn, header) |> List.first() + if value, do: "#{header}: #{value}", else: nil + end + + line + end) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + {:ok, result} + end + + defp verify_signature(public_key_pem, signed_string, signature_base64) do + public_key = + :public_key.pem_decode(public_key_pem) + |> hd() + |> :public_key.pem_entry_decode() + + signature = Base.decode64!(signature_base64) + + :public_key.verify(signed_string, :sha256, signature, public_key) + end +end diff --git a/lib/nulla/key_gen.ex b/lib/nulla/key_gen.ex new file mode 100644 index 0000000..8a75cb5 --- /dev/null +++ b/lib/nulla/key_gen.ex @@ -0,0 +1,24 @@ +defmodule Nulla.KeyGen do + def gen do + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + + private_pem = + :public_key.pem_encode([entry]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + [private_key_code] = :public_key.pem_decode(private_pem) + private_key = :public_key.pem_entry_decode(private_key_code) + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + + public_pem = + :public_key.pem_encode([public_key]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + {public_pem, private_pem} + end +end diff --git a/lib/nulla/media_attachments.ex b/lib/nulla/media_attachments.ex new file mode 100644 index 0000000..334319b --- /dev/null +++ b/lib/nulla/media_attachments.ex @@ -0,0 +1,104 @@ +defmodule Nulla.MediaAttachments do + @moduledoc """ + The MediaAttachments context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Returns the list of media_attachments. + + ## Examples + + iex> list_media_attachments() + [%MediaAttachment{}, ...] + + """ + def list_media_attachments do + Repo.all(MediaAttachment) + end + + @doc """ + Gets a single media_attachment. + + Raises `Ecto.NoResultsError` if the Media attachment does not exist. + + ## Examples + + iex> get_media_attachment!(123) + %MediaAttachment{} + + iex> get_media_attachment!(456) + ** (Ecto.NoResultsError) + + """ + def get_media_attachment!(id), do: Repo.get!(MediaAttachment, id) + + @doc """ + Creates a media_attachment. + + ## Examples + + iex> create_media_attachment(%{field: value}) + {:ok, %MediaAttachment{}} + + iex> create_media_attachment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_media_attachment(attrs \\ %{}) do + %MediaAttachment{} + |> MediaAttachment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a media_attachment. + + ## Examples + + iex> update_media_attachment(media_attachment, %{field: new_value}) + {:ok, %MediaAttachment{}} + + iex> update_media_attachment(media_attachment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_media_attachment(%MediaAttachment{} = media_attachment, attrs) do + media_attachment + |> MediaAttachment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a media_attachment. + + ## Examples + + iex> delete_media_attachment(media_attachment) + {:ok, %MediaAttachment{}} + + iex> delete_media_attachment(media_attachment) + {:error, %Ecto.Changeset{}} + + """ + def delete_media_attachment(%MediaAttachment{} = media_attachment) do + Repo.delete(media_attachment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking media_attachment changes. + + ## Examples + + iex> change_media_attachment(media_attachment) + %Ecto.Changeset{data: %MediaAttachment{}} + + """ + def change_media_attachment(%MediaAttachment{} = media_attachment, attrs \\ %{}) do + MediaAttachment.changeset(media_attachment, attrs) + end +end diff --git a/lib/nulla/media_attachments/media_attachment.ex b/lib/nulla/media_attachments/media_attachment.ex new file mode 100644 index 0000000..6efd9e1 --- /dev/null +++ b/lib/nulla/media_attachments/media_attachment.ex @@ -0,0 +1,35 @@ +defmodule Nulla.MediaAttachments.MediaAttachment do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Notes.Note + + @primary_key {:id, :integer, autogenerate: false} + schema "media_attachments" do + field :type, :string + field :mediaType, :string + field :url, :string + field :name, :string + field :width, :integer + field :height, :integer + + belongs_to :note, Note + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(media_attachment, attrs) do + media_attachment + |> cast(attrs, [:type, :mediaType, :url, :name, :width, :height, :note_id]) + |> maybe_put_id() + |> validate_required([:type, :mediaType, :url, :note_id]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/notes.ex b/lib/nulla/notes.ex new file mode 100644 index 0000000..99c4999 --- /dev/null +++ b/lib/nulla/notes.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Notes do + @moduledoc """ + The Notes context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Notes.Note + + @doc """ + Returns the list of notes. + + ## Examples + + iex> list_notes() + [%Note{}, ...] + + """ + def list_notes do + Repo.all(Note) + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + + ## Examples + + iex> create_note(%{field: value}) + {:ok, %Note{}} + + iex> create_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_note(attrs \\ %{}) do + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a note. + + ## Examples + + iex> update_note(note, %{field: new_value}) + {:ok, %Note{}} + + iex> update_note(note, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_note(%Note{} = note, attrs) do + note + |> Note.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a note. + + ## Examples + + iex> delete_note(note) + {:ok, %Note{}} + + iex> delete_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_note(%Note{} = note) do + Repo.delete(note) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking note changes. + + ## Examples + + iex> change_note(note) + %Ecto.Changeset{data: %Note{}} + + """ + def change_note(%Note{} = note, attrs \\ %{}) do + Note.changeset(note, attrs) + end +end diff --git a/lib/nulla/notes/note.ex b/lib/nulla/notes/note.ex new file mode 100644 index 0000000..d4ba8b1 --- /dev/null +++ b/lib/nulla/notes/note.ex @@ -0,0 +1,60 @@ +defmodule Nulla.Notes.Note do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + alias Nulla.MediaAttachments.MediaAttachment + + @primary_key {:id, :integer, autogenerate: false} + schema "notes" do + field :inReplyTo, :string + field :published, :utc_datetime + field :url, :string + field :visibility, :string + field :to, {:array, :string} + field :cc, {:array, :string} + field :sensitive, :boolean, default: false + field :content, :string + field :language, :string + + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [ + :inReplyTo, + :published, + :url, + :visibility, + :to, + :cc, + :sensitive, + :content, + :language, + :actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :published, + :url, + :visibility, + :to, + :cc, + :content, + :language, + :actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/relations.ex b/lib/nulla/relations.ex new file mode 100644 index 0000000..088d01a --- /dev/null +++ b/lib/nulla/relations.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Relations do + @moduledoc """ + The Relations context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Relations.Relation + + @doc """ + Returns the list of relations. + + ## Examples + + iex> list_relations() + [%Relation{}, ...] + + """ + def list_relations do + Repo.all(Relation) + end + + @doc """ + Gets a single relation. + + Raises `Ecto.NoResultsError` if the Relation does not exist. + + ## Examples + + iex> get_relation!(123) + %Relation{} + + iex> get_relation!(456) + ** (Ecto.NoResultsError) + + """ + def get_relation!(id), do: Repo.get!(Relation, id) + + @doc """ + Creates a relation. + + ## Examples + + iex> create_relation(%{field: value}) + {:ok, %Relation{}} + + iex> create_relation(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_relation(attrs \\ %{}) do + %Relation{} + |> Relation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a relation. + + ## Examples + + iex> update_relation(relation, %{field: new_value}) + {:ok, %Relation{}} + + iex> update_relation(relation, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_relation(%Relation{} = relation, attrs) do + relation + |> Relation.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a relation. + + ## Examples + + iex> delete_relation(relation) + {:ok, %Relation{}} + + iex> delete_relation(relation) + {:error, %Ecto.Changeset{}} + + """ + def delete_relation(%Relation{} = relation) do + Repo.delete(relation) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking relation changes. + + ## Examples + + iex> change_relation(relation) + %Ecto.Changeset{data: %Relation{}} + + """ + def change_relation(%Relation{} = relation, attrs \\ %{}) do + Relation.changeset(relation, attrs) + end +end diff --git a/lib/nulla/relations/relation.ex b/lib/nulla/relations/relation.ex new file mode 100644 index 0000000..8ac7e38 --- /dev/null +++ b/lib/nulla/relations/relation.ex @@ -0,0 +1,71 @@ +defmodule Nulla.Relations.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + + schema "relations" do + field :following, :boolean, default: false + field :followed_by, :boolean, default: false + field :showing_replies, :boolean, default: true + field :showings_reblogs, :boolean, default: true + field :notifying, :boolean, default: false + field :muting, :boolean, default: false + field :muting_notifications, :boolean, default: false + field :blocking, :boolean, default: false + field :blocked_by, :boolean, default: false + field :domain_blocking, :boolean, default: false + field :requested, :boolean, default: false + field :note, :string + + belongs_to :local_actor, Actor + belongs_to :remote_actor, Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(relation, attrs) do + relation + |> cast(attrs, [ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex new file mode 100644 index 0000000..110fa2c --- /dev/null +++ b/lib/nulla/sender.ex @@ -0,0 +1,24 @@ +defmodule Nulla.Sender do + alias Nulla.HTTPSignature + alias NullaWeb.ActivityJSON + + def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do + body = Jason.encode!(ActivityJSON.activitypub(activity)) + headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) + request = Finch.build(method, inbox, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Activity #{activity.id} delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver activity #{activity.id}") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Activity #{activity.id} delivery failed") + {:error, reason} + end + end +end diff --git a/lib/nulla/snowflake.ex b/lib/nulla/snowflake.ex new file mode 100644 index 0000000..5b7c88e --- /dev/null +++ b/lib/nulla/snowflake.ex @@ -0,0 +1,62 @@ +defmodule Nulla.Snowflake do + use GenServer + import Bitwise + + @epoch :calendar.datetime_to_gregorian_seconds({{2020, 1, 1}, {0, 0, 0}}) * 1000 + @max_sequence 4095 + @time_shift 22 + @worker_shift 12 + + def start_link(worker_id) when worker_id in 0..1023 do + GenServer.start_link(__MODULE__, worker_id, name: __MODULE__) + end + + def next_id do + GenServer.call(__MODULE__, :next_id) + end + + @impl true + def init(worker_id) do + {:ok, %{last_timestamp: -1, sequence: 0, worker_id: worker_id}} + end + + @impl true + def handle_call(:next_id, _from, %{worker_id: worker_id} = state) do + timestamp = current_time() + + {timestamp, sequence, state} = + cond do + timestamp < state.last_timestamp -> + raise "Clock moved backwards" + + timestamp == state.last_timestamp and state.sequence < @max_sequence -> + {timestamp, state.sequence + 1, %{state | sequence: state.sequence + 1}} + + timestamp == state.last_timestamp -> + wait_for_next_millisecond(timestamp) + new_timestamp = current_time() + {new_timestamp, 0, %{state | last_timestamp: new_timestamp, sequence: 0}} + + true -> + {timestamp, 0, %{state | last_timestamp: timestamp, sequence: 0}} + end + + raw_id = + ((timestamp - @epoch) <<< @time_shift) + |> bor(worker_id <<< @worker_shift) + |> bor(sequence) + + id = Bitwise.band(raw_id, 0x7FFFFFFFFFFFFFFF) + + {:reply, id, %{state | last_timestamp: timestamp, sequence: sequence}} + end + + defp current_time do + System.system_time(:millisecond) + end + + defp wait_for_next_millisecond(last_ts) do + :timer.sleep(1) + if current_time() <= last_ts, do: wait_for_next_millisecond(last_ts), else: :ok + end +end diff --git a/lib/nulla/types/string_or_json.ex b/lib/nulla/types/string_or_json.ex new file mode 100644 index 0000000..4463c32 --- /dev/null +++ b/lib/nulla/types/string_or_json.ex @@ -0,0 +1,46 @@ +defmodule Nulla.Types.StringOrJson do + @behaviour Ecto.Type + + @impl true + def type, do: :string + + @impl true + def cast(value) when is_map(value) or is_list(value), do: {:ok, value} + + @impl true + def cast(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def cast(_), do: :error + + @impl true + def dump(value) when is_map(value) or is_list(value), do: Jason.encode(value) + + @impl true + def dump(value) when is_binary(value), do: {:ok, value} + + @impl true + def dump(_), do: :error + + @impl true + def load(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def load(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2), do: term1 == term2 +end diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex index ea6a36d..8ca2517 100644 --- a/lib/nulla_web/components/layouts/root.html.heex +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@ + {@inner_content} diff --git a/lib/nulla_web/controllers/activity_controller.ex b/lib/nulla_web/controllers/activity_controller.ex new file mode 100644 index 0000000..9f25573 --- /dev/null +++ b/lib/nulla_web/controllers/activity_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActivityController do + use NullaWeb, :controller + + alias Nulla.Activities + alias Nulla.Activities.Activity + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + activities = Activities.list_activities() + render(conn, :index, activities: activities) + end + + def create(conn, %{"activity" => activity_params}) do + with {:ok, %Activity{} = activity} <- Activities.create_activity(activity_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/activities/#{activity}") + |> render(:show, activity: activity) + end + end + + def show(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + render(conn, :show, activity: activity) + end + + def update(conn, %{"id" => id, "activity" => activity_params}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{} = activity} <- Activities.update_activity(activity, activity_params) do + render(conn, :show, activity: activity) + end + end + + def delete(conn, %{"id" => id}) do + activity = Activities.get_activity!(id) + + with {:ok, %Activity{}} <- Activities.delete_activity(activity) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/activity_json.ex b/lib/nulla_web/controllers/activity_json.ex new file mode 100644 index 0000000..4713bed --- /dev/null +++ b/lib/nulla_web/controllers/activity_json.ex @@ -0,0 +1,41 @@ +defmodule NullaWeb.ActivityJSON do + alias Nulla.Activities.Activity + + @doc """ + Renders a list of activities. + """ + def index(%{activities: activities}) do + %{data: for(activity <- activities, do: data(activity))} + end + + @doc """ + Renders a single activity. + """ + def show(%{activity: activity}) do + %{data: data(activity)} + end + + defp data(%Activity{} = activity) do + %{ + id: activity.id, + ap_id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + } + end + + def activitypub(%Activity{} = activity) do + Jason.OrderedObject.new( + "@context": "https://www.w3.org/ns/activitystreams", + id: activity.ap_id, + type: activity.type, + actor: activity.actor, + object: activity.object, + to: activity.to, + cc: activity.cc + ) + end +end diff --git a/lib/nulla_web/controllers/actor_controller.ex b/lib/nulla_web/controllers/actor_controller.ex new file mode 100644 index 0000000..cf9028e --- /dev/null +++ b/lib/nulla_web/controllers/actor_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.ActorController do + use NullaWeb, :controller + + alias Nulla.Actors + alias Nulla.Actors.Actor + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + actors = Actors.list_actors() + render(conn, :index, actors: actors) + end + + def create(conn, %{"actor" => actor_params}) do + with {:ok, %Actor{} = actor} <- Actors.create_actor(actor_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/actors/#{actor}") + |> render(:show, actor: actor) + end + end + + def show(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + render(conn, :show, actor: actor) + end + + def update(conn, %{"id" => id, "actor" => actor_params}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{} = actor} <- Actors.update_actor(actor, actor_params) do + render(conn, :show, actor: actor) + end + end + + def delete(conn, %{"id" => id}) do + actor = Actors.get_actor!(id) + + with {:ok, %Actor{}} <- Actors.delete_actor(actor) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/actor_json.ex b/lib/nulla_web/controllers/actor_json.ex new file mode 100644 index 0000000..ca59e62 --- /dev/null +++ b/lib/nulla_web/controllers/actor_json.ex @@ -0,0 +1,49 @@ +defmodule NullaWeb.ActorJSON do + alias Nulla.Actors.Actor + + @doc """ + Renders a list of actors. + """ + def index(%{actors: actors}) do + %{data: for(actor <- actors, do: data(actor))} + end + + @doc """ + Renders a single actor. + """ + def show(%{actor: actor}) do + %{data: data(actor)} + end + + defp data(%Actor{} = actor) do + %{ + id: actor.id, + acct: actor.acct, + ap_id: actor.ap_id, + type: actor.type, + following: actor.following, + followers: actor.followers, + inbox: actor.inbox, + outbox: actor.outbox, + featured: actor.featured, + featuredTags: actor.featuredTags, + preferredUsername: actor.preferredUsername, + name: actor.name, + summary: actor.summary, + url: actor.url, + manuallyApprovesFollowers: actor.manuallyApprovesFollowers, + discoverable: actor.discoverable, + indexable: actor.indexable, + published: actor.published, + memorial: actor.memorial, + publicKey: actor.publicKey, + tag: actor.tag, + attachment: actor.attachment, + endpoints: actor.endpoints, + icon: actor.icon, + image: actor.image, + vcard_bday: actor.vcard_bday, + vcard_Address: actor.vcard_Address + } + end +end diff --git a/lib/nulla_web/controllers/changeset_json.ex b/lib/nulla_web/controllers/changeset_json.ex new file mode 100644 index 0000000..9eca0fd --- /dev/null +++ b/lib/nulla_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule NullaWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)} + end + + defp translate_error({msg, opts}) do + # You can make use of gettext to translate error messages by + # uncommenting and adjusting the following code: + + # if count = opts[:count] do + # Gettext.dngettext(NullaWeb.Gettext, "errors", msg, msg, count, opts) + # else + # Gettext.dgettext(NullaWeb.Gettext, "errors", msg, opts) + # end + + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) + end) + end +end diff --git a/lib/nulla_web/controllers/fallback_controller.ex b/lib/nulla_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..9d731e1 --- /dev/null +++ b/lib/nulla_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule NullaWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use NullaWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: NullaWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: NullaWeb.ErrorHTML, json: NullaWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/nulla_web/controllers/media_attachment_controller.ex b/lib/nulla_web/controllers/media_attachment_controller.ex new file mode 100644 index 0000000..840f503 --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_controller.ex @@ -0,0 +1,45 @@ +defmodule NullaWeb.MediaAttachmentController do + use NullaWeb, :controller + + alias Nulla.MediaAttachments + alias Nulla.MediaAttachments.MediaAttachment + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + media_attachments = MediaAttachments.list_media_attachments() + render(conn, :index, media_attachments: media_attachments) + end + + def create(conn, %{"media_attachment" => media_attachment_params}) do + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.create_media_attachment(media_attachment_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/media_attachments/#{media_attachment}") + |> render(:show, media_attachment: media_attachment) + end + end + + def show(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + render(conn, :show, media_attachment: media_attachment) + end + + def update(conn, %{"id" => id, "media_attachment" => media_attachment_params}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{} = media_attachment} <- + MediaAttachments.update_media_attachment(media_attachment, media_attachment_params) do + render(conn, :show, media_attachment: media_attachment) + end + end + + def delete(conn, %{"id" => id}) do + media_attachment = MediaAttachments.get_media_attachment!(id) + + with {:ok, %MediaAttachment{}} <- MediaAttachments.delete_media_attachment(media_attachment) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/media_attachment_json.ex b/lib/nulla_web/controllers/media_attachment_json.ex new file mode 100644 index 0000000..8c24f1f --- /dev/null +++ b/lib/nulla_web/controllers/media_attachment_json.ex @@ -0,0 +1,29 @@ +defmodule NullaWeb.MediaAttachmentJSON do + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Renders a list of media_attachments. + """ + def index(%{media_attachments: media_attachments}) do + %{data: for(media_attachment <- media_attachments, do: data(media_attachment))} + end + + @doc """ + Renders a single media_attachment. + """ + def show(%{media_attachment: media_attachment}) do + %{data: data(media_attachment)} + end + + defp data(%MediaAttachment{} = media_attachment) do + %{ + id: media_attachment.id, + type: media_attachment.type, + mediaType: media_attachment.mediaType, + url: media_attachment.url, + name: media_attachment.name, + width: media_attachment.width, + height: media_attachment.height + } + end +end diff --git a/lib/nulla_web/controllers/note_controller.ex b/lib/nulla_web/controllers/note_controller.ex new file mode 100644 index 0000000..925ebe3 --- /dev/null +++ b/lib/nulla_web/controllers/note_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.NoteController do + use NullaWeb, :controller + + alias Nulla.Notes + alias Nulla.Notes.Note + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + notes = Notes.list_notes() + render(conn, :index, notes: notes) + end + + def create(conn, %{"note" => note_params}) do + with {:ok, %Note{} = note} <- Notes.create_note(note_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/notes/#{note}") + |> render(:show, note: note) + end + end + + def show(conn, %{"id" => id}) do + note = Notes.get_note!(id) + render(conn, :show, note: note) + end + + def update(conn, %{"id" => id, "note" => note_params}) do + note = Notes.get_note!(id) + + with {:ok, %Note{} = note} <- Notes.update_note(note, note_params) do + render(conn, :show, note: note) + end + end + + def delete(conn, %{"id" => id}) do + note = Notes.get_note!(id) + + with {:ok, %Note{}} <- Notes.delete_note(note) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/note_json.ex b/lib/nulla_web/controllers/note_json.ex new file mode 100644 index 0000000..a7773d9 --- /dev/null +++ b/lib/nulla_web/controllers/note_json.ex @@ -0,0 +1,32 @@ +defmodule NullaWeb.NoteJSON do + alias Nulla.Notes.Note + + @doc """ + Renders a list of notes. + """ + def index(%{notes: notes}) do + %{data: for(note <- notes, do: data(note))} + end + + @doc """ + Renders a single note. + """ + def show(%{note: note}) do + %{data: data(note)} + end + + defp data(%Note{} = note) do + %{ + id: note.id, + inReplyTo: note.inReplyTo, + published: note.published, + url: note.url, + visibility: note.visibility, + to: note.to, + cc: note.cc, + sensitive: note.sensitive, + content: note.content, + language: note.language + } + end +end diff --git a/lib/nulla_web/controllers/relation_controller.ex b/lib/nulla_web/controllers/relation_controller.ex new file mode 100644 index 0000000..cc5f968 --- /dev/null +++ b/lib/nulla_web/controllers/relation_controller.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.RelationController do + use NullaWeb, :controller + + alias Nulla.Relations + alias Nulla.Relations.Relation + + action_fallback NullaWeb.FallbackController + + def index(conn, _params) do + relations = Relations.list_relations() + render(conn, :index, relations: relations) + end + + def create(conn, %{"relation" => relation_params}) do + with {:ok, %Relation{} = relation} <- Relations.create_relation(relation_params) do + conn + |> put_status(:created) + |> put_resp_header("location", ~p"/api/relations/#{relation}") + |> render(:show, relation: relation) + end + end + + def show(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + render(conn, :show, relation: relation) + end + + def update(conn, %{"id" => id, "relation" => relation_params}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{} = relation} <- Relations.update_relation(relation, relation_params) do + render(conn, :show, relation: relation) + end + end + + def delete(conn, %{"id" => id}) do + relation = Relations.get_relation!(id) + + with {:ok, %Relation{}} <- Relations.delete_relation(relation) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/nulla_web/controllers/relation_json.ex b/lib/nulla_web/controllers/relation_json.ex new file mode 100644 index 0000000..42ab693 --- /dev/null +++ b/lib/nulla_web/controllers/relation_json.ex @@ -0,0 +1,35 @@ +defmodule NullaWeb.RelationJSON do + alias Nulla.Relations.Relation + + @doc """ + Renders a list of relations. + """ + def index(%{relations: relations}) do + %{data: for(relation <- relations, do: data(relation))} + end + + @doc """ + Renders a single relation. + """ + def show(%{relation: relation}) do + %{data: data(relation)} + end + + defp data(%Relation{} = relation) do + %{ + id: relation.id, + following: relation.following, + followed_by: relation.followed_by, + showing_replies: relation.showing_replies, + showings_reblogs: relation.showings_reblogs, + notifying: relation.notifying, + muting: relation.muting, + muting_notifications: relation.muting_notifications, + blocking: relation.blocking, + blocked_by: relation.blocked_by, + domain_blocking: relation.domain_blocking, + requested: relation.requested, + note: relation.note + } + end +end diff --git a/lib/nulla_web/controllers/user_session_controller.ex b/lib/nulla_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..ba818f6 --- /dev/null +++ b/lib/nulla_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule NullaWeb.UserSessionController do + use NullaWeb, :controller + + alias Nulla.Accounts + alias NullaWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/nulla_web/live/user_confirmation_instructions_live.ex b/lib/nulla_web/live/user_confirmation_instructions_live.ex new file mode 100644 index 0000000..8de0f24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_instructions_live.ex @@ -0,0 +1,51 @@ +defmodule NullaWeb.UserConfirmationInstructionsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + No confirmation instructions received? + <:subtitle>We'll send a new confirmation link to your inbox + + + <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Resend confirmation instructions + + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + end + + info = + "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_confirmation_live.ex b/lib/nulla_web/live/user_confirmation_live.ex new file mode 100644 index 0000000..0aabe24 --- /dev/null +++ b/lib/nulla_web/live/user_confirmation_live.ex @@ -0,0 +1,58 @@ +defmodule NullaWeb.UserConfirmationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(%{live_action: :edit} = assigns) do + ~H""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + form = to_form(%{"token" => token}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} + end + + # Do not log in the user after confirmation to avoid a + # leaked token giving the user access to the account. + def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do + case Accounts.confirm_user(token) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "User confirmed successfully.") + |> redirect(to: ~p"/")} + + :error -> + # If there is a current user and the account was already confirmed, + # then odds are that the confirmation link was already visited, either + # by some automation or by the user themselves, so we redirect without + # a warning message. + case socket.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + {:noreply, redirect(socket, to: ~p"/")} + + %{} -> + {:noreply, + socket + |> put_flash(:error, "User confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/")} + end + end + end +end diff --git a/lib/nulla_web/live/user_forgot_password_live.ex b/lib/nulla_web/live/user_forgot_password_live.ex new file mode 100644 index 0000000..21e3cfc --- /dev/null +++ b/lib/nulla_web/live/user_forgot_password_live.ex @@ -0,0 +1,50 @@ +defmodule NullaWeb.UserForgotPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Forgot your password? + <:subtitle>We'll send a password reset link to your inbox + + + <.simple_form for={@form} id="reset_password_form" phx-submit="send_email"> + <.input field={@form[:email]} type="email" placeholder="Email" required /> + <:actions> + <.button phx-disable-with="Sending..." class="w-full"> + Send password reset instructions + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(_params, _session, socket) do + {:ok, assign(socket, form: to_form(%{}, as: "user"))} + end + + def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_user_reset_password_instructions( + user, + &url(~p"/users/reset_password/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions to reset your password shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> redirect(to: ~p"/")} + end +end diff --git a/lib/nulla_web/live/user_login_live.ex b/lib/nulla_web/live/user_login_live.ex new file mode 100644 index 0000000..a005b1d --- /dev/null +++ b/lib/nulla_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule NullaWeb.UserLoginLive do + use NullaWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Log in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Logging in..." class="w-full"> + Log in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = Phoenix.Flash.get(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/lib/nulla_web/live/user_registration_live.ex b/lib/nulla_web/live/user_registration_live.ex new file mode 100644 index 0000000..e35ad1f --- /dev/null +++ b/lib/nulla_web/live/user_registration_live.ex @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + alias Nulla.Accounts.User + + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/nulla_web/live/user_reset_password_live.ex b/lib/nulla_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..04ddbc9 --- /dev/null +++ b/lib/nulla_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserResetPasswordLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/lib/nulla_web/live/user_settings_live.ex b/lib/nulla_web/live/user_settings_live.ex new file mode 100644 index 0000000..8932554 --- /dev/null +++ b/lib/nulla_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule NullaWeb.UserSettingsLive do + use NullaWeb, :live_view + + alias Nulla.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/lib/nulla_web/router.ex b/lib/nulla_web/router.ex index 95706c9..0693036 100644 --- a/lib/nulla_web/router.ex +++ b/lib/nulla_web/router.ex @@ -1,6 +1,8 @@ defmodule NullaWeb.Router do use NullaWeb, :router + import NullaWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,10 +10,12 @@ defmodule NullaWeb.Router do plug :put_root_layout, html: {NullaWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do plug :accepts, ["json"] + plug :fetch_api_user end scope "/", NullaWeb do @@ -20,10 +24,15 @@ defmodule NullaWeb.Router do get "/", PageController, :home end - # Other scopes may use custom stacks. - # scope "/api", NullaWeb do - # pipe_through :api - # end + scope "/api", NullaWeb do + pipe_through :api + + resources "/actors", ActorController, except: [:new, :edit] + resources "/notes", NoteController, except: [:new, :edit] + resources "/media_attachments", MediaAttachmentController, except: [:new, :edit] + resources "/relations", RelationController, except: [:new, :edit] + resources "/activities", ActivityController, except: [:new, :edit] + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:nulla, :dev_routes) do @@ -41,4 +50,42 @@ defmodule NullaWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", NullaWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{NullaWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + + post "/users/log_in", UserSessionController, :create + end + + scope "/", NullaWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end + end + + scope "/", NullaWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + + live_session :current_user, + on_mount: [{NullaWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end + end end diff --git a/lib/nulla_web/user_auth.ex b/lib/nulla_web/user_auth.ex new file mode 100644 index 0000000..49913f7 --- /dev/null +++ b/lib/nulla_web/user_auth.ex @@ -0,0 +1,245 @@ +defmodule NullaWeb.UserAuth do + use NullaWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Nulla.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_nulla_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + NullaWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule NullaWeb.PageLive do + use NullaWeb, :live_view + + on_mount {NullaWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{NullaWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + def fetch_api_user(conn, _opts) do + if Mix.env() != :test do + with ["Bearer " <> token] <- get_req_header(conn, "authorization"), + {:ok, user} <- Accounts.fetch_user_by_api_token(token) do + assign(conn, :current_user, user) + else + _ -> + conn + |> send_resp(:unauthorized, "No access for you") + |> halt() + end + else + conn + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index 09ceeec..5343832 100644 --- a/mix.exs +++ b/mix.exs @@ -32,6 +32,7 @@ defmodule Nulla.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.21"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, diff --git a/mix.lock b/mix.lock index 3283437..a852d05 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,14 @@ %{ "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, diff --git a/priv/repo/migrations/20250701093122_create_users_auth_tables.exs b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs new file mode 100644 index 0000000..7068f76 --- /dev/null +++ b/priv/repo/migrations/20250701093122_create_users_auth_tables.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users, primary_key: false) do + add :id, :bigint, primary_key: true + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20250701133126_create_actors.exs b/priv/repo/migrations/20250701133126_create_actors.exs new file mode 100644 index 0000000..4dfe0ea --- /dev/null +++ b/priv/repo/migrations/20250701133126_create_actors.exs @@ -0,0 +1,41 @@ +defmodule Nulla.Repo.Migrations.CreateActors do + use Ecto.Migration + + def change do + create table(:actors, primary_key: false) do + add :id, :bigint, primary_key: true + add :acct, :string + add :ap_id, :string + add :type, :string + add :following, :string + add :followers, :string + add :inbox, :string + add :outbox, :string + add :featured, :string + add :featuredTags, :string + add :preferredUsername, :string + add :name, :string + add :summary, :string + add :url, :string + add :manuallyApprovesFollowers, :boolean, default: false, null: false + add :discoverable, :boolean, default: true, null: false + add :indexable, :boolean, default: true, null: false + add :published, :utc_datetime + add :memorial, :boolean, default: false, null: false + add :publicKey, :map + add :privateKeyPem, :text + add :tag, {:array, :map}, default: [] + add :attachment, {:array, :map}, default: [] + add :endpoints, :map + add :icon, :map + add :image, :map + add :vcard_bday, :date + add :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:actors, [:acct]) + create unique_index(:actors, [:ap_id]) + end +end diff --git a/priv/repo/migrations/20250702091405_create_notes.exs b/priv/repo/migrations/20250702091405_create_notes.exs new file mode 100644 index 0000000..6f65471 --- /dev/null +++ b/priv/repo/migrations/20250702091405_create_notes.exs @@ -0,0 +1,23 @@ +defmodule Nulla.Repo.Migrations.CreateNotes do + use Ecto.Migration + + def change do + create table(:notes, primary_key: false) do + add :id, :bigint, primary_key: true + add :inReplyTo, :string + add :published, :utc_datetime + add :url, :string + add :visibility, :string + add :to, {:array, :string} + add :cc, {:array, :string} + add :sensitive, :boolean, default: false, null: false + add :content, :string + add :language, :string + add :actor_id, references(:actors, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:notes, [:actor_id]) + end +end diff --git a/priv/repo/migrations/20250702091750_create_media_attachments.exs b/priv/repo/migrations/20250702091750_create_media_attachments.exs new file mode 100644 index 0000000..44e69c6 --- /dev/null +++ b/priv/repo/migrations/20250702091750_create_media_attachments.exs @@ -0,0 +1,18 @@ +defmodule Nulla.Repo.Migrations.CreateMediaAttachments do + use Ecto.Migration + + def change do + create table(:media_attachments, primary_key: false) do + add :id, :bigint, primary_key: true + add :type, :string + add :mediaType, :string + add :url, :string + add :name, :string + add :width, :integer + add :height, :integer + add :note_id, references(:notes, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20250702151805_create_activities.exs b/priv/repo/migrations/20250702151805_create_activities.exs new file mode 100644 index 0000000..67c7253 --- /dev/null +++ b/priv/repo/migrations/20250702151805_create_activities.exs @@ -0,0 +1,21 @@ +defmodule Nulla.Repo.Migrations.CreateActivities do + use Ecto.Migration + + def change do + create table(:activities, primary_key: false) do + add :id, :bigint, primary_key: true + add :ap_id, :string + add :type, :string + add :actor, :string + add :object, :string + add :to, {:array, :string} + add :cc, {:array, :string} + + timestamps(type: :utc_datetime) + end + + create index(:activities, [:ap_id]) + create index(:activities, [:type]) + create index(:activities, [:actor]) + end +end diff --git a/priv/repo/migrations/20250702152953_create_relations.exs b/priv/repo/migrations/20250702152953_create_relations.exs new file mode 100644 index 0000000..a8d7caf --- /dev/null +++ b/priv/repo/migrations/20250702152953_create_relations.exs @@ -0,0 +1,30 @@ +defmodule Nulla.Repo.Migrations.CreateRelations do + use Ecto.Migration + + def change do + create table(:relations) do + add :following, :boolean, default: false, null: false + add :followed_by, :boolean, default: false, null: false + add :showing_replies, :boolean, default: false, null: false + add :showings_reblogs, :boolean, default: false, null: false + add :notifying, :boolean, default: false, null: false + add :muting, :boolean, default: false, null: false + add :muting_notifications, :boolean, default: false, null: false + add :blocking, :boolean, default: false, null: false + add :blocked_by, :boolean, default: false, null: false + add :domain_blocking, :boolean, default: false, null: false + add :requested, :boolean, default: false, null: false + add :note, :string + + add :local_actor_id, references(:actors, type: :bigint), null: false + add :remote_actor_id, references(:actors, type: :bigint), null: false + + timestamps(type: :utc_datetime) + end + + create index(:relations, [:local_actor_id]) + create index(:relations, [:remote_actor_id]) + + create unique_index(:relations, [:local_actor_id, :remote_actor_id]) + end +end diff --git a/test/nulla/accounts_test.exs b/test/nulla/accounts_test.exs new file mode 100644 index 0000000..a5c5724 --- /dev/null +++ b/test/nulla/accounts_test.exs @@ -0,0 +1,508 @@ +defmodule Nulla.AccountsTest do + use Nulla.DataCase + + alias Nulla.Accounts + + import Nulla.AccountsFixtures + alias Nulla.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/nulla/activities_test.exs b/test/nulla/activities_test.exs new file mode 100644 index 0000000..ffd8f08 --- /dev/null +++ b/test/nulla/activities_test.exs @@ -0,0 +1,84 @@ +defmodule Nulla.ActivitiesTest do + use Nulla.DataCase + + alias Nulla.Activities + + describe "activities" do + alias Nulla.Activities.Activity + + import Nulla.ActivitiesFixtures + + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + test "list_activities/0 returns all activities" do + activity = activity_fixture() + assert Activities.list_activities() == [activity] + end + + test "get_activity!/1 returns the activity with given id" do + activity = activity_fixture() + assert Activities.get_activity!(activity.id) == activity + end + + test "create_activity/1 with valid data creates a activity" do + valid_attrs = %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + + assert {:ok, %Activity{} = activity} = Activities.create_activity(valid_attrs) + assert activity.type == "some type" + assert activity.cc == ["option1", "option2"] + assert activity.to == ["option1", "option2"] + assert activity.ap_id == "some ap_id" + assert activity.actor == "some actor" + assert activity.object == "some object" + end + + test "create_activity/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_activity(@invalid_attrs) + end + + test "update_activity/2 with valid data updates the activity" do + activity = activity_fixture() + + update_attrs = %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + + assert {:ok, %Activity{} = activity} = Activities.update_activity(activity, update_attrs) + assert activity.type == "some updated type" + assert activity.cc == ["option1"] + assert activity.to == ["option1"] + assert activity.ap_id == "some updated ap_id" + assert activity.actor == "some updated actor" + assert activity.object == "some updated object" + end + + test "update_activity/2 with invalid data returns error changeset" do + activity = activity_fixture() + assert {:error, %Ecto.Changeset{}} = Activities.update_activity(activity, @invalid_attrs) + assert activity == Activities.get_activity!(activity.id) + end + + test "delete_activity/1 deletes the activity" do + activity = activity_fixture() + assert {:ok, %Activity{}} = Activities.delete_activity(activity) + assert_raise Ecto.NoResultsError, fn -> Activities.get_activity!(activity.id) end + end + + test "change_activity/1 returns a activity changeset" do + activity = activity_fixture() + assert %Ecto.Changeset{} = Activities.change_activity(activity) + end + end +end diff --git a/test/nulla/actors_test.exs b/test/nulla/actors_test.exs new file mode 100644 index 0000000..bc2a88d --- /dev/null +++ b/test/nulla/actors_test.exs @@ -0,0 +1,210 @@ +defmodule Nulla.ActorsTest do + use Nulla.DataCase + + alias Nulla.KeyGen + alias Nulla.Actors + + describe "actors" do + alias Nulla.Actors.Actor + + import Nulla.ActorsFixtures + + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + privateKeyPem: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + test "list_actors/0 returns all actors" do + actor = actor_fixture() + assert Actors.list_actors() == [actor] + end + + test "get_actor!/1 returns the actor with given id" do + actor = actor_fixture() + assert Actors.get_actor!(actor.id) == actor + end + + test "create_actor/1 with valid data creates a actor" do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + valid_attrs = %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: username, + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.create_actor(valid_attrs) + assert actor.name == "some name" + assert actor.tag == [] + assert actor.type == "some type" + assert actor.image == %{} + assert actor.url == "some url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some following" + assert actor.followers == "some followers" + assert actor.inbox == "some inbox" + assert actor.outbox == "some outbox" + assert actor.featured == "some featured" + assert actor.featuredTags == "some featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some summary" + assert actor.manuallyApprovesFollowers == true + assert actor.discoverable == true + assert actor.indexable == true + assert actor.published == ~U[2025-06-30 13:31:00Z] + assert actor.memorial == true + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-06-30] + assert actor.vcard_Address == "some vcard_Address" + end + + test "create_actor/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Actors.create_actor(@invalid_attrs) + end + + test "update_actor/2 with valid data updates the actor" do + actor = actor_fixture() + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + update_attrs = %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: username, + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + + assert {:ok, %Actor{} = actor} = Actors.update_actor(actor, update_attrs) + assert actor.name == "some updated name" + assert actor.tag == [] + assert actor.type == "some updated type" + assert actor.image == %{} + assert actor.url == "some updated url" + assert actor.acct == "#{username}@localhost" + assert actor.ap_id == "http://localhost/users/#{username}" + assert actor.following == "some updated following" + assert actor.followers == "some updated followers" + assert actor.inbox == "some updated inbox" + assert actor.outbox == "some updated outbox" + assert actor.featured == "some updated featured" + assert actor.featuredTags == "some updated featuredTags" + assert actor.preferredUsername == username + assert actor.summary == "some updated summary" + assert actor.manuallyApprovesFollowers == false + assert actor.discoverable == false + assert actor.indexable == false + assert actor.published == ~U[2025-07-01 13:31:00Z] + assert actor.memorial == false + assert actor.publicKey["publicKeyPem"] == publicKeyPem + assert actor.privateKeyPem == privateKeyPem + assert actor.attachment == [] + assert actor.endpoints == %{} + assert actor.icon == %{} + assert actor.vcard_bday == ~D[2025-07-01] + assert actor.vcard_Address == "some updated vcard_Address" + end + + test "update_actor/2 with invalid data returns error changeset" do + actor = actor_fixture() + assert {:error, %Ecto.Changeset{}} = Actors.update_actor(actor, @invalid_attrs) + assert actor == Actors.get_actor!(actor.id) + end + + test "delete_actor/1 deletes the actor" do + actor = actor_fixture() + assert {:ok, %Actor{}} = Actors.delete_actor(actor) + assert_raise Ecto.NoResultsError, fn -> Actors.get_actor!(actor.id) end + end + + test "change_actor/1 returns a actor changeset" do + actor = actor_fixture() + assert %Ecto.Changeset{} = Actors.change_actor(actor) + end + end +end diff --git a/test/nulla/media_attachments_test.exs b/test/nulla/media_attachments_test.exs new file mode 100644 index 0000000..9b509eb --- /dev/null +++ b/test/nulla/media_attachments_test.exs @@ -0,0 +1,113 @@ +defmodule Nulla.MediaAttachmentsTest do + use Nulla.DataCase + + alias Nulla.MediaAttachments + + describe "media_attachments" do + alias Nulla.MediaAttachments.MediaAttachment + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + @invalid_attrs %{ + name: nil, + type: nil, + width: nil, + url: nil, + mediaType: nil, + height: nil, + note_id: nil + } + + test "list_media_attachments/0 returns all media_attachments" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.list_media_attachments() == [media_attachment] + end + + test "get_media_attachment!/1 returns the media_attachment with given id" do + media_attachment = media_attachment_fixture() + assert MediaAttachments.get_media_attachment!(media_attachment.id) == media_attachment + end + + test "create_media_attachment/1 with valid data creates a media_attachment" do + note = note_fixture() + + valid_attrs = %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.create_media_attachment(valid_attrs) + + assert media_attachment.name == "some name" + assert media_attachment.type == "some type" + assert media_attachment.width == 42 + assert media_attachment.url == "some url" + assert media_attachment.mediaType == "some mediaType" + assert media_attachment.height == 42 + assert media_attachment.note_id == note.id + end + + test "create_media_attachment/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.create_media_attachment(@invalid_attrs) + end + + test "update_media_attachment/2 with valid data updates the media_attachment" do + note = note_fixture() + media_attachment = media_attachment_fixture() + + update_attrs = %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43, + note_id: note.id + } + + assert {:ok, %MediaAttachment{} = media_attachment} = + MediaAttachments.update_media_attachment(media_attachment, update_attrs) + + assert media_attachment.name == "some updated name" + assert media_attachment.type == "some updated type" + assert media_attachment.width == 43 + assert media_attachment.url == "some updated url" + assert media_attachment.mediaType == "some updated mediaType" + assert media_attachment.height == 43 + assert media_attachment.note_id == note.id + end + + test "update_media_attachment/2 with invalid data returns error changeset" do + media_attachment = media_attachment_fixture() + + assert {:error, %Ecto.Changeset{}} = + MediaAttachments.update_media_attachment(media_attachment, @invalid_attrs) + + assert media_attachment == MediaAttachments.get_media_attachment!(media_attachment.id) + end + + test "delete_media_attachment/1 deletes the media_attachment" do + media_attachment = media_attachment_fixture() + + assert {:ok, %MediaAttachment{}} = + MediaAttachments.delete_media_attachment(media_attachment) + + assert_raise Ecto.NoResultsError, fn -> + MediaAttachments.get_media_attachment!(media_attachment.id) + end + end + + test "change_media_attachment/1 returns a media_attachment changeset" do + media_attachment = media_attachment_fixture() + assert %Ecto.Changeset{} = MediaAttachments.change_media_attachment(media_attachment) + end + end +end diff --git a/test/nulla/notes_test.exs b/test/nulla/notes_test.exs new file mode 100644 index 0000000..d200212 --- /dev/null +++ b/test/nulla/notes_test.exs @@ -0,0 +1,115 @@ +defmodule Nulla.NotesTest do + use Nulla.DataCase + + alias Nulla.Notes + + describe "notes" do + alias Nulla.Notes.Note + + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil, + actor_id: nil + } + + test "list_notes/0 returns all notes" do + note = note_fixture() + assert Notes.list_notes() == [note] + end + + test "get_note!/1 returns the note with given id" do + note = note_fixture() + assert Notes.get_note!(note.id) == note + end + + test "create_note/1 with valid data creates a note" do + actor = actor_fixture() + + valid_attrs = %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.create_note(valid_attrs) + assert note.sensitive == true + assert note.cc == ["option1", "option2"] + assert note.to == ["option1", "option2"] + assert note.url == "some url" + assert note.language == "some language" + assert note.inReplyTo == "some inReplyTo" + assert note.published == ~U[2025-07-01 09:17:00Z] + assert note.visibility == "some visibility" + assert note.content == "some content" + assert note.actor_id == actor.id + end + + test "create_note/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Notes.create_note(@invalid_attrs) + end + + test "update_note/2 with valid data updates the note" do + actor = actor_fixture() + note = note_fixture() + + update_attrs = %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content", + actor_id: actor.id + } + + assert {:ok, %Note{} = note} = Notes.update_note(note, update_attrs) + assert note.sensitive == false + assert note.cc == ["option1"] + assert note.to == ["option1"] + assert note.url == "some updated url" + assert note.language == "some updated language" + assert note.inReplyTo == "some updated inReplyTo" + assert note.published == ~U[2025-07-02 09:17:00Z] + assert note.visibility == "some updated visibility" + assert note.content == "some updated content" + assert note.actor_id == actor.id + end + + test "update_note/2 with invalid data returns error changeset" do + note = note_fixture() + assert {:error, %Ecto.Changeset{}} = Notes.update_note(note, @invalid_attrs) + assert note == Notes.get_note!(note.id) + end + + test "delete_note/1 deletes the note" do + note = note_fixture() + assert {:ok, %Note{}} = Notes.delete_note(note) + assert_raise Ecto.NoResultsError, fn -> Notes.get_note!(note.id) end + end + + test "change_note/1 returns a note changeset" do + note = note_fixture() + assert %Ecto.Changeset{} = Notes.change_note(note) + end + end +end diff --git a/test/nulla/relations_test.exs b/test/nulla/relations_test.exs new file mode 100644 index 0000000..a4e45c5 --- /dev/null +++ b/test/nulla/relations_test.exs @@ -0,0 +1,137 @@ +defmodule Nulla.RelationsTest do + use Nulla.DataCase + + alias Nulla.Relations + + describe "relations" do + alias Nulla.Relations.Relation + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil, + local_actor_id: nil, + remote_actor_id: nil + } + + test "list_relations/0 returns all relations" do + relation = relation_fixture() + assert Relations.list_relations() == [relation] + end + + test "get_relation!/1 returns the relation with given id" do + relation = relation_fixture() + assert Relations.get_relation!(relation.id) == relation + end + + test "create_relation/1 with valid data creates a relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + valid_attrs = %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.create_relation(valid_attrs) + assert relation.following == true + assert relation.followed_by == true + assert relation.showing_replies == true + assert relation.showings_reblogs == true + assert relation.notifying == true + assert relation.muting == true + assert relation.muting_notifications == true + assert relation.blocking == true + assert relation.blocked_by == true + assert relation.domain_blocking == true + assert relation.requested == true + assert relation.note == "some note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "create_relation/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Relations.create_relation(@invalid_attrs) + end + + test "update_relation/2 with valid data updates the relation" do + local_actor = actor_fixture() + remote_actor = actor_fixture() + relation = relation_fixture() + + update_attrs = %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note", + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + } + + assert {:ok, %Relation{} = relation} = Relations.update_relation(relation, update_attrs) + assert relation.following == false + assert relation.followed_by == false + assert relation.showing_replies == false + assert relation.showings_reblogs == false + assert relation.notifying == false + assert relation.muting == false + assert relation.muting_notifications == false + assert relation.blocking == false + assert relation.blocked_by == false + assert relation.domain_blocking == false + assert relation.requested == false + assert relation.note == "some updated note" + assert relation.local_actor_id == local_actor.id + assert relation.remote_actor_id == remote_actor.id + end + + test "update_relation/2 with invalid data returns error changeset" do + relation = relation_fixture() + assert {:error, %Ecto.Changeset{}} = Relations.update_relation(relation, @invalid_attrs) + assert relation == Relations.get_relation!(relation.id) + end + + test "delete_relation/1 deletes the relation" do + relation = relation_fixture() + assert {:ok, %Relation{}} = Relations.delete_relation(relation) + assert_raise Ecto.NoResultsError, fn -> Relations.get_relation!(relation.id) end + end + + test "change_relation/1 returns a relation changeset" do + relation = relation_fixture() + assert %Ecto.Changeset{} = Relations.change_relation(relation) + end + end +end diff --git a/test/nulla_web/controllers/activity_controller_test.exs b/test/nulla_web/controllers/activity_controller_test.exs new file mode 100644 index 0000000..1ae210c --- /dev/null +++ b/test/nulla_web/controllers/activity_controller_test.exs @@ -0,0 +1,107 @@ +defmodule NullaWeb.ActivityControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActivitiesFixtures + + alias Nulla.Activities.Activity + + @create_attrs %{ + type: "some type", + cc: ["option1", "option2"], + to: ["option1", "option2"], + ap_id: "some ap_id", + actor: "some actor", + object: "some object" + } + @update_attrs %{ + type: "some updated type", + cc: ["option1"], + to: ["option1"], + ap_id: "some updated ap_id", + actor: "some updated actor", + object: "some updated object" + } + @invalid_attrs %{type: nil, cc: nil, to: nil, ap_id: nil, actor: nil, object: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all activities", %{conn: conn} do + conn = get(conn, ~p"/api/activities") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create activity" do + test "renders activity when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some actor", + "ap_id" => "some ap_id", + "cc" => ["option1", "option2"], + "object" => "some object", + "to" => ["option1", "option2"], + "type" => "some type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/activities", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update activity" do + setup [:create_activity] + + test "renders activity when data is valid", %{ + conn: conn, + activity: %Activity{id: id} = activity + } do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/activities/#{id}") + + assert %{ + "id" => ^id, + "actor" => "some updated actor", + "ap_id" => "some updated ap_id", + "cc" => ["option1"], + "object" => "some updated object", + "to" => ["option1"], + "type" => "some updated type" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, activity: activity} do + conn = put(conn, ~p"/api/activities/#{activity}", activity: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete activity" do + setup [:create_activity] + + test "deletes chosen activity", %{conn: conn, activity: activity} do + conn = delete(conn, ~p"/api/activities/#{activity}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/activities/#{activity}") + end + end + end + + defp create_activity(_) do + activity = activity_fixture() + %{activity: activity} + end +end diff --git a/test/nulla_web/controllers/actor_controller_test.exs b/test/nulla_web/controllers/actor_controller_test.exs new file mode 100644 index 0000000..39f5224 --- /dev/null +++ b/test/nulla_web/controllers/actor_controller_test.exs @@ -0,0 +1,211 @@ +defmodule NullaWeb.ActorControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + + alias Nulla.Actors.Actor + + @create_attrs %{ + name: "some name", + tag: [], + type: "some type", + image: %{}, + url: "some url", + acct: "some acct", + ap_id: "some ap_id", + following: "some following", + followers: "some followers", + inbox: "some inbox", + outbox: "some outbox", + featured: "some featured", + featuredTags: "some featuredTags", + preferredUsername: "some preferredUsername", + summary: "some summary", + manuallyApprovesFollowers: true, + discoverable: true, + indexable: true, + published: ~U[2025-06-30 13:31:00Z], + memorial: true, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-06-30], + vcard_Address: "some vcard_Address" + } + @update_attrs %{ + name: "some updated name", + tag: [], + type: "some updated type", + image: %{}, + url: "some updated url", + acct: "some updated acct", + ap_id: "some updated ap_id", + following: "some updated following", + followers: "some updated followers", + inbox: "some updated inbox", + outbox: "some updated outbox", + featured: "some updated featured", + featuredTags: "some updated featuredTags", + preferredUsername: "some updated preferredUsername", + summary: "some updated summary", + manuallyApprovesFollowers: false, + discoverable: false, + indexable: false, + published: ~U[2025-07-01 13:31:00Z], + memorial: false, + publicKey: %{}, + attachment: [], + endpoints: %{}, + icon: %{}, + vcard_bday: ~D[2025-07-01], + vcard_Address: "some updated vcard_Address" + } + @invalid_attrs %{ + name: nil, + tag: nil, + type: nil, + image: nil, + url: nil, + acct: nil, + ap_id: nil, + following: nil, + followers: nil, + inbox: nil, + outbox: nil, + featured: nil, + featuredTags: nil, + preferredUsername: nil, + summary: nil, + manuallyApprovesFollowers: nil, + discoverable: nil, + indexable: nil, + published: nil, + memorial: nil, + publicKey: nil, + attachment: nil, + endpoints: nil, + icon: nil, + vcard_bday: nil, + vcard_Address: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all actors", %{conn: conn} do + conn = get(conn, ~p"/api/actors") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create actor" do + test "renders actor when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some acct", + "ap_id" => "some ap_id", + "attachment" => [], + "discoverable" => true, + "endpoints" => %{}, + "featured" => "some featured", + "featuredTags" => "some featuredTags", + "followers" => "some followers", + "following" => "some following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some inbox", + "indexable" => true, + "manuallyApprovesFollowers" => true, + "memorial" => true, + "name" => "some name", + "outbox" => "some outbox", + "preferredUsername" => "some preferredUsername", + "publicKey" => %{}, + "published" => "2025-06-30T13:31:00Z", + "summary" => "some summary", + "tag" => [], + "type" => "some type", + "url" => "some url", + "vcard_Address" => "some vcard_Address", + "vcard_bday" => "2025-06-30" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/actors", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update actor" do + setup [:create_actor] + + test "renders actor when data is valid", %{conn: conn, actor: %Actor{id: id} = actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/actors/#{id}") + + assert %{ + "id" => ^id, + "acct" => "some updated acct", + "ap_id" => "some updated ap_id", + "attachment" => [], + "discoverable" => false, + "endpoints" => %{}, + "featured" => "some updated featured", + "featuredTags" => "some updated featuredTags", + "followers" => "some updated followers", + "following" => "some updated following", + "icon" => %{}, + "image" => %{}, + "inbox" => "some updated inbox", + "indexable" => false, + "manuallyApprovesFollowers" => false, + "memorial" => false, + "name" => "some updated name", + "outbox" => "some updated outbox", + "preferredUsername" => "some updated preferredUsername", + "publicKey" => %{}, + "published" => "2025-07-01T13:31:00Z", + "summary" => "some updated summary", + "tag" => [], + "type" => "some updated type", + "url" => "some updated url", + "vcard_Address" => "some updated vcard_Address", + "vcard_bday" => "2025-07-01" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, actor: actor} do + conn = put(conn, ~p"/api/actors/#{actor}", actor: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete actor" do + setup [:create_actor] + + test "deletes chosen actor", %{conn: conn, actor: actor} do + conn = delete(conn, ~p"/api/actors/#{actor}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/actors/#{actor}") + end + end + end + + defp create_actor(_) do + actor = actor_fixture() + %{actor: actor} + end +end diff --git a/test/nulla_web/controllers/media_attachment_controller_test.exs b/test/nulla_web/controllers/media_attachment_controller_test.exs new file mode 100644 index 0000000..b443cc7 --- /dev/null +++ b/test/nulla_web/controllers/media_attachment_controller_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.MediaAttachmentControllerTest do + use NullaWeb.ConnCase + + import Nulla.NotesFixtures + import Nulla.MediaAttachmentsFixtures + + alias Nulla.MediaAttachments.MediaAttachment + + @create_attrs %{ + name: "some name", + type: "some type", + width: 42, + url: "some url", + mediaType: "some mediaType", + height: 42 + } + @update_attrs %{ + name: "some updated name", + type: "some updated type", + width: 43, + url: "some updated url", + mediaType: "some updated mediaType", + height: 43 + } + @invalid_attrs %{name: nil, type: nil, width: nil, url: nil, mediaType: nil, height: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all media_attachments", %{conn: conn} do + conn = get(conn, ~p"/api/media_attachments") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create media_attachment" do + test "renders media_attachment when data is valid", %{conn: conn} do + note = note_fixture() + + create_attrs = Map.merge(@create_attrs, %{note_id: note.id}) + + conn = post(conn, ~p"/api/media_attachments", media_attachment: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 42, + "mediaType" => "some mediaType", + "name" => "some name", + "type" => "some type", + "url" => "some url", + "width" => 42 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/media_attachments", media_attachment: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update media_attachment" do + setup [:create_media_attachment] + + test "renders media_attachment when data is valid", %{ + conn: conn, + media_attachment: %MediaAttachment{id: id} = media_attachment + } do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", media_attachment: @update_attrs) + + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/media_attachments/#{id}") + + assert %{ + "id" => ^id, + "height" => 43, + "mediaType" => "some updated mediaType", + "name" => "some updated name", + "type" => "some updated type", + "url" => "some updated url", + "width" => 43 + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, media_attachment: media_attachment} do + conn = + put(conn, ~p"/api/media_attachments/#{media_attachment}", + media_attachment: @invalid_attrs + ) + + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete media_attachment" do + setup [:create_media_attachment] + + test "deletes chosen media_attachment", %{conn: conn, media_attachment: media_attachment} do + conn = delete(conn, ~p"/api/media_attachments/#{media_attachment}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/media_attachments/#{media_attachment}") + end + end + end + + defp create_media_attachment(_) do + media_attachment = media_attachment_fixture() + %{media_attachment: media_attachment} + end +end diff --git a/test/nulla_web/controllers/note_controller_test.exs b/test/nulla_web/controllers/note_controller_test.exs new file mode 100644 index 0000000..b5855fa --- /dev/null +++ b/test/nulla_web/controllers/note_controller_test.exs @@ -0,0 +1,131 @@ +defmodule NullaWeb.NoteControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + import Nulla.NotesFixtures + + alias Nulla.Notes.Note + + @create_attrs %{ + sensitive: true, + cc: ["option1", "option2"], + to: ["option1", "option2"], + url: "some url", + language: "some language", + inReplyTo: "some inReplyTo", + published: ~U[2025-07-01 09:17:00Z], + visibility: "some visibility", + content: "some content" + } + @update_attrs %{ + sensitive: false, + cc: ["option1"], + to: ["option1"], + url: "some updated url", + language: "some updated language", + inReplyTo: "some updated inReplyTo", + published: ~U[2025-07-02 09:17:00Z], + visibility: "some updated visibility", + content: "some updated content" + } + @invalid_attrs %{ + sensitive: nil, + cc: nil, + to: nil, + url: nil, + language: nil, + inReplyTo: nil, + published: nil, + visibility: nil, + content: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all notes", %{conn: conn} do + conn = get(conn, ~p"/api/notes") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create note" do + test "renders note when data is valid", %{conn: conn} do + actor = actor_fixture() + + create_attrs = Map.merge(@create_attrs, %{actor_id: actor.id}) + + conn = post(conn, ~p"/api/notes", note: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1", "option2"], + "content" => "some content", + "inReplyTo" => "some inReplyTo", + "language" => "some language", + "published" => "2025-07-01T09:17:00Z", + "sensitive" => true, + "to" => ["option1", "option2"], + "url" => "some url", + "visibility" => "some visibility" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/notes", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update note" do + setup [:create_note] + + test "renders note when data is valid", %{conn: conn, note: %Note{id: id} = note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/notes/#{id}") + + assert %{ + "id" => ^id, + "cc" => ["option1"], + "content" => "some updated content", + "inReplyTo" => "some updated inReplyTo", + "language" => "some updated language", + "published" => "2025-07-02T09:17:00Z", + "sensitive" => false, + "to" => ["option1"], + "url" => "some updated url", + "visibility" => "some updated visibility" + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, note: note} do + conn = put(conn, ~p"/api/notes/#{note}", note: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete note" do + setup [:create_note] + + test "deletes chosen note", %{conn: conn, note: note} do + conn = delete(conn, ~p"/api/notes/#{note}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/notes/#{note}") + end + end + end + + defp create_note(_) do + note = note_fixture() + %{note: note} + end +end diff --git a/test/nulla_web/controllers/relation_controller_test.exs b/test/nulla_web/controllers/relation_controller_test.exs new file mode 100644 index 0000000..a221d33 --- /dev/null +++ b/test/nulla_web/controllers/relation_controller_test.exs @@ -0,0 +1,154 @@ +defmodule NullaWeb.RelationControllerTest do + use NullaWeb.ConnCase + + import Nulla.ActorsFixtures + import Nulla.RelationsFixtures + + alias Nulla.Relations.Relation + + @create_attrs %{ + following: true, + followed_by: true, + showing_replies: true, + showings_reblogs: true, + notifying: true, + muting: true, + muting_notifications: true, + blocking: true, + blocked_by: true, + domain_blocking: true, + requested: true, + note: "some note" + } + @update_attrs %{ + following: false, + followed_by: false, + showing_replies: false, + showings_reblogs: false, + notifying: false, + muting: false, + muting_notifications: false, + blocking: false, + blocked_by: false, + domain_blocking: false, + requested: false, + note: "some updated note" + } + @invalid_attrs %{ + following: nil, + followed_by: nil, + showing_replies: nil, + showings_reblogs: nil, + notifying: nil, + muting: nil, + muting_notifications: nil, + blocking: nil, + blocked_by: nil, + domain_blocking: nil, + requested: nil, + note: nil + } + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all relations", %{conn: conn} do + conn = get(conn, ~p"/api/relations") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create relation" do + test "renders relation when data is valid", %{conn: conn} do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + create_attrs = + Map.merge(@create_attrs, %{ + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + + conn = post(conn, ~p"/api/relations", relation: create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => true, + "blocking" => true, + "domain_blocking" => true, + "followed_by" => true, + "following" => true, + "muting" => true, + "muting_notifications" => true, + "note" => "some note", + "notifying" => true, + "requested" => true, + "showing_replies" => true, + "showings_reblogs" => true + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/relations", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update relation" do + setup [:create_relation] + + test "renders relation when data is valid", %{ + conn: conn, + relation: %Relation{id: id} = relation + } do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/relations/#{id}") + + assert %{ + "id" => ^id, + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "followed_by" => false, + "following" => false, + "muting" => false, + "muting_notifications" => false, + "note" => "some updated note", + "notifying" => false, + "requested" => false, + "showing_replies" => false, + "showings_reblogs" => false + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, relation: relation} do + conn = put(conn, ~p"/api/relations/#{relation}", relation: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete relation" do + setup [:create_relation] + + test "deletes chosen relation", %{conn: conn, relation: relation} do + conn = delete(conn, ~p"/api/relations/#{relation}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/relations/#{relation}") + end + end + end + + defp create_relation(_) do + relation = relation_fixture() + %{relation: relation} + end +end diff --git a/test/nulla_web/controllers/user_session_controller_test.exs b/test/nulla_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..b4d82f0 --- /dev/null +++ b/test/nulla_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule NullaWeb.UserSessionControllerTest do + use NullaWeb.ConnCase, async: true + + import Nulla.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, ~p"/") + response = html_response(conn, 200) + assert response =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log_out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_nulla_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/nulla_web/live/user_confirmation_instructions_live_test.exs b/test/nulla_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..0b13cdd --- /dev/null +++ b/test/nulla_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule NullaWeb.UserConfirmationInstructionsLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_confirmation_live_test.exs b/test/nulla_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..561e757 --- /dev/null +++ b/test/nulla_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule NullaWeb.UserConfirmationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/nulla_web/live/user_forgot_password_live_test.exs b/test/nulla_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..848b93f --- /dev/null +++ b/test/nulla_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule NullaWeb.UserForgotPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + alias Nulla.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/nulla_web/live/user_login_live_test.exs b/test/nulla_web/live/user_login_live_test.exs new file mode 100644 index 0000000..cc83e65 --- /dev/null +++ b/test/nulla_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserLoginLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/nulla_web/live/user_registration_live_test.exs b/test/nulla_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..f85952d --- /dev/null +++ b/test/nulla_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule NullaWeb.UserRegistrationLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # Now do a logged in request and assert on the menu + conn = get(conn, "/") + response = html_response(conn, 200) + assert response =~ email + assert response =~ "Settings" + assert response =~ "Log out" + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/nulla_web/live/user_reset_password_live_test.exs b/test/nulla_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..0039d31 --- /dev/null +++ b/test/nulla_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule NullaWeb.UserResetPasswordLiveTest do + use NullaWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + alias Nulla.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/nulla_web/live/user_settings_live_test.exs b/test/nulla_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..ee8c098 --- /dev/null +++ b/test/nulla_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule NullaWeb.UserSettingsLiveTest do + use NullaWeb.ConnCase, async: true + + alias Nulla.Accounts + import Phoenix.LiveViewTest + import Nulla.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/nulla_web/user_auth_test.exs b/test/nulla_web/user_auth_test.exs new file mode 100644 index 0000000..328a049 --- /dev/null +++ b/test/nulla_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule NullaWeb.UserAuthTest do + use NullaWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias Nulla.Accounts + alias NullaWeb.UserAuth + import Nulla.AccountsFixtures + + @remember_me_cookie "_nulla_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, NullaWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + NullaWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: NullaWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 10ed035..ce34e8d 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule NullaWeb.ConnCase do Nulla.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = Nulla.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Nulla.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..e37764f --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Nulla.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Nulla.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex new file mode 100644 index 0000000..d9884ac --- /dev/null +++ b/test/support/fixtures/activities_fixtures.ex @@ -0,0 +1,25 @@ +defmodule Nulla.ActivitiesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Activities` context. + """ + + @doc """ + Generate a activity. + """ + def activity_fixture(attrs \\ %{}) do + {:ok, activity} = + attrs + |> Enum.into(%{ + actor: "some actor", + ap_id: "some ap_id", + cc: ["option1", "option2"], + object: "some object", + to: ["option1", "option2"], + type: "some type" + }) + |> Nulla.Activities.create_activity() + + activity + end +end diff --git a/test/support/fixtures/actors_fixtures.ex b/test/support/fixtures/actors_fixtures.ex new file mode 100644 index 0000000..2381cbe --- /dev/null +++ b/test/support/fixtures/actors_fixtures.ex @@ -0,0 +1,60 @@ +defmodule Nulla.ActorsFixtures do + alias Nulla.KeyGen + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Actors` context. + """ + + @doc """ + Generate a actor. + """ + def actor_fixture(attrs \\ %{}) do + username = "test#{System.unique_integer()}" + {publicKeyPem, privateKeyPem} = KeyGen.gen() + + attrs = + Enum.into(attrs, %{ + acct: "#{username}@localhost", + ap_id: "http://localhost/users/#{username}", + attachment: [], + discoverable: true, + endpoints: %{}, + featured: "some featured", + featuredTags: "some featuredTags", + followers: "some followers", + following: "some following", + icon: %{}, + image: %{}, + inbox: "some inbox", + indexable: true, + manuallyApprovesFollowers: true, + memorial: true, + name: "some name", + outbox: "some outbox", + preferredUsername: username, + publicKey: %{ + "id" => "http://localhost/users/#{username}#main-key", + "owner" => "http://localhost/users/#{username}", + "publicKeyPem" => publicKeyPem + }, + privateKeyPem: privateKeyPem, + published: ~U[2025-06-30 13:31:00Z], + summary: "some summary", + tag: [], + type: "some type", + url: "some url", + vcard_Address: "some vcard_Address", + vcard_bday: ~D[2025-06-30] + }) + + case Nulla.Actors.create_actor(attrs) do + {:ok, actor} -> + actor + + {:error, changeset} -> + IO.inspect(changeset, label: "Actor creation failed") + raise "Failed to create actor fixture" + end + end +end diff --git a/test/support/fixtures/media_attachments_fixtures.ex b/test/support/fixtures/media_attachments_fixtures.ex new file mode 100644 index 0000000..c9bd54f --- /dev/null +++ b/test/support/fixtures/media_attachments_fixtures.ex @@ -0,0 +1,30 @@ +defmodule Nulla.MediaAttachmentsFixtures do + import Nulla.NotesFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.MediaAttachments` context. + """ + + @doc """ + Generate a media_attachment. + """ + def media_attachment_fixture(attrs \\ %{}) do + note = note_fixture() + + {:ok, media_attachment} = + attrs + |> Enum.into(%{ + height: 42, + mediaType: "some mediaType", + name: "some name", + type: "some type", + url: "some url", + width: 42, + note_id: note.id + }) + |> Nulla.MediaAttachments.create_media_attachment() + + media_attachment + end +end diff --git a/test/support/fixtures/notes_fixtures.ex b/test/support/fixtures/notes_fixtures.ex new file mode 100644 index 0000000..a3d34b1 --- /dev/null +++ b/test/support/fixtures/notes_fixtures.ex @@ -0,0 +1,33 @@ +defmodule Nulla.NotesFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Notes` context. + """ + + @doc """ + Generate a note. + """ + def note_fixture(attrs \\ %{}) do + actor = actor_fixture() + + {:ok, note} = + attrs + |> Enum.into(%{ + cc: ["option1", "option2"], + content: "some content", + inReplyTo: "some inReplyTo", + language: "some language", + published: ~U[2025-07-01 09:17:00Z], + sensitive: true, + to: ["option1", "option2"], + url: "some url", + visibility: "some visibility", + actor_id: actor.id + }) + |> Nulla.Notes.create_note() + + note + end +end diff --git a/test/support/fixtures/relations_fixtures.ex b/test/support/fixtures/relations_fixtures.ex new file mode 100644 index 0000000..ca7ef41 --- /dev/null +++ b/test/support/fixtures/relations_fixtures.ex @@ -0,0 +1,38 @@ +defmodule Nulla.RelationsFixtures do + import Nulla.ActorsFixtures + + @moduledoc """ + This module defines test helpers for creating + entities via the `Nulla.Relations` context. + """ + + @doc """ + Generate a relation. + """ + def relation_fixture(attrs \\ %{}) do + local_actor = actor_fixture() + remote_actor = actor_fixture() + + {:ok, relation} = + attrs + |> Enum.into(%{ + blocked_by: true, + blocking: true, + domain_blocking: true, + followed_by: true, + following: true, + muting: true, + muting_notifications: true, + note: "some note", + notifying: true, + requested: true, + showing_replies: true, + showings_reblogs: true, + local_actor_id: local_actor.id, + remote_actor_id: remote_actor.id + }) + |> Nulla.Relations.create_relation() + + relation + end +end