This commit is contained in:
Mirai Kumiko 2025-07-04 10:25:40 +02:00
parent b35e18cd20
commit 82f55f7afe
Signed by: miraikumiko
GPG key ID: 3F178B1B5E0CB278
80 changed files with 6687 additions and 5 deletions

View file

@ -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

View file

@ -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

377
lib/nulla/accounts.ex Normal file
View file

@ -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

171
lib/nulla/accounts/user.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

104
lib/nulla/activities.ex Normal file
View file

@ -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

View file

@ -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

104
lib/nulla/actors.ex Normal file
View file

@ -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

95
lib/nulla/actors/actor.ex Normal file
View file

@ -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

View file

@ -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

111
lib/nulla/http_signature.ex Normal file
View file

@ -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

24
lib/nulla/key_gen.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

104
lib/nulla/notes.ex Normal file
View file

@ -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

60
lib/nulla/notes/note.ex Normal file
View file

@ -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

104
lib/nulla/relations.ex Normal file
View file

@ -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

View file

@ -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

24
lib/nulla/sender.ex Normal file
View file

@ -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

62
lib/nulla/snowflake.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -12,6 +12,47 @@
</script>
</head>
<body class="bg-white">
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
{@current_user.email}
</li>
<li>
<.link
href={~p"/users/settings"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Settings
</.link>
</li>
<li>
<.link
href={~p"/users/log_out"}
method="delete"
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log out
</.link>
</li>
<% else %>
<li>
<.link
href={~p"/users/register"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Register
</.link>
</li>
<li>
<.link
href={~p"/users/log_in"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log in
</.link>
</li>
<% end %>
</ul>
{@inner_content}
</body>
</html>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,51 @@
defmodule NullaWeb.UserConfirmationInstructionsLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
No confirmation instructions received?
<:subtitle>We'll send a new confirmation link to your inbox</:subtitle>
</.header>
<.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
</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
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

View file

@ -0,0 +1,58 @@
defmodule NullaWeb.UserConfirmationLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Confirm Account</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<:actions>
<.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
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

View file

@ -0,0 +1,50 @@
defmodule NullaWeb.UserForgotPasswordLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
</.header>
<.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
</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
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

View file

@ -0,0 +1,43 @@
defmodule NullaWeb.UserLoginLive do
use NullaWeb, :live_view
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.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
</.link>
for an account now.
</:subtitle>
</.header>
<.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?
</.link>
</:actions>
<:actions>
<.button phx-disable-with="Logging in..." class="w-full">
Log in <span aria-hidden="true"></span>
</.button>
</:actions>
</.simple_form>
</div>
"""
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

View file

@ -0,0 +1,87 @@
defmodule NullaWeb.UserRegistrationLive do
use NullaWeb, :live_view
alias Nulla.Accounts
alias Nulla.Accounts.User
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.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
</.link>
to your account now.
</:subtitle>
</.header>
<.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.
</.error>
<.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</.button>
</:actions>
</.simple_form>
</div>
"""
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

View file

@ -0,0 +1,89 @@
defmodule NullaWeb.UserResetPasswordLive do
use NullaWeb, :live_view
alias Nulla.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.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.
</.error>
<.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</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
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

View file

@ -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</:subtitle>
</.header>
<div class="space-y-12 divide-y">
<div>
<.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</.button>
</:actions>
</.simple_form>
</div>
<div>
<.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
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
value={@current_email}
/>
<.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</.button>
</:actions>
</.simple_form>
</div>
</div>
"""
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

View file

@ -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

245
lib/nulla_web/user_auth.ex Normal file
View file

@ -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

View file

@ -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"},

View file

@ -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"},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

210
test/nulla/actors_test.exs Normal file
View file

@ -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

View file

@ -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

115
test/nulla/notes_test.exs Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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