defmodule GitGud.Accounts.TwoFactorTest do
use GitGud.DataCase, async: false
alias GitGud.Accounts.TwoFactor
import GitGud.AccountsFixtures
describe "enroll + verify_code" do
test "enable with a valid code stores the encrypted secret and returns recovery codes" do
user = user_fixture()
secret = TwoFactor.new_secret()
code = NimbleTOTP.verification_code(secret)
assert {:ok, recovery_codes} = TwoFactor.enable(user, secret, code)
assert length(recovery_codes) == 10
assert Enum.all?(recovery_codes, &is_binary/1)
updated = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
assert TwoFactor.enabled?(updated)
assert is_binary(updated.totp_ciphertext)
assert is_binary(updated.totp_iv)
end
test "enable with an invalid code refuses to store anything" do
user = user_fixture()
secret = TwoFactor.new_secret()
assert {:error, :invalid_code} = TwoFactor.enable(user, secret, "000000")
reread = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
refute TwoFactor.enabled?(reread)
assert is_nil(reread.totp_ciphertext)
end
test "verify_code accepts the current code and rejects a wrong one" do
user = user_fixture()
secret = TwoFactor.new_secret()
{:ok, _} = TwoFactor.enable(user, secret, NimbleTOTP.verification_code(secret))
user = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
assert :ok = TwoFactor.verify_code(user, NimbleTOTP.verification_code(secret))
assert {:error, :invalid_code} = TwoFactor.verify_code(user, "000001")
end
test "verify_code returns :not_enrolled when no secret stored" do
user = user_fixture()
assert {:error, :not_enrolled} = TwoFactor.verify_code(user, "123456")
end
end
describe "recovery codes" do
setup do
user = user_fixture()
secret = TwoFactor.new_secret()
{:ok, codes} = TwoFactor.enable(user, secret, NimbleTOTP.verification_code(secret))
user = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
{:ok, user: user, codes: codes}
end
test "verify_recovery_code consumes the code once", %{user: user, codes: [code | _]} do
assert :ok = TwoFactor.verify_recovery_code(user, code)
# Second presentation of the same code fails.
assert {:error, :invalid_code} = TwoFactor.verify_recovery_code(user, code)
end
test "verify_recovery_code rejects unknown codes", %{user: user} do
assert {:error, :invalid_code} = TwoFactor.verify_recovery_code(user, "notreal000")
end
test "remaining_recovery_codes decrements on use", %{user: user, codes: [code | _]} do
assert TwoFactor.remaining_recovery_codes(user) == 10
:ok = TwoFactor.verify_recovery_code(user, code)
assert TwoFactor.remaining_recovery_codes(user) == 9
end
test "regenerate_recovery_codes! invalidates all old codes", %{user: user, codes: [old | _]} do
new_codes = TwoFactor.regenerate_recovery_codes!(user)
assert length(new_codes) == 10
refute old in new_codes
assert {:error, :invalid_code} = TwoFactor.verify_recovery_code(user, old)
assert :ok = TwoFactor.verify_recovery_code(user, hd(new_codes))
end
end
describe "disable" do
test "clears the secret and all recovery codes when no other factor" do
user = user_fixture()
secret = TwoFactor.new_secret()
{:ok, _} = TwoFactor.enable(user, secret, NimbleTOTP.verification_code(secret))
user = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
assert {:ok, disabled} = TwoFactor.disable(user)
refute TwoFactor.enabled?(disabled)
assert is_nil(disabled.totp_ciphertext)
assert TwoFactor.remaining_recovery_codes(disabled) == 0
end
test "keeps recovery codes when the user still has a WebAuthn credential" do
user = user_fixture()
secret = TwoFactor.new_secret()
{:ok, _} = TwoFactor.enable(user, secret, NimbleTOTP.verification_code(secret))
%GitGud.Accounts.WebauthnCredential{}
|> GitGud.Accounts.WebauthnCredential.create_changeset(%{
user_id: user.id,
credential_id: :crypto.strong_rand_bytes(32),
public_key: :erlang.term_to_binary(%{stub: true}),
sign_count: 0,
name: "yubi"
})
|> GitGud.Repo.insert!()
user = GitGud.Repo.get!(GitGud.Accounts.User, user.id)
{:ok, disabled} = TwoFactor.disable(user)
refute TwoFactor.enabled?(disabled)
assert TwoFactor.remaining_recovery_codes(disabled) == 10
end
end
describe "ensure_recovery_codes/1" do
test "mints codes if user has none" do
user = user_fixture()
assert codes = TwoFactor.ensure_recovery_codes(user)
assert is_list(codes)
assert length(codes) == 10
end
test "returns nil and leaves codes untouched if user already has unused codes" do
user = user_fixture()
first = TwoFactor.ensure_recovery_codes(user)
assert is_list(first)
assert is_nil(TwoFactor.ensure_recovery_codes(user))
assert TwoFactor.remaining_recovery_codes(user) == 10
end
end
describe "instance enforcement" do
setup do
Application.put_env(:git_gud, TwoFactor, required: true, grace_period_days: 14)
on_exit(fn -> Application.delete_env(:git_gud, TwoFactor) end)
:ok
end
test "user inside grace window gets {:grace, days_left}" do
user = %GitGud.Accounts.User{
confirmed_at: DateTime.utc_now(:second),
inserted_at: DateTime.utc_now(:second)
}
assert {:grace, days} = TwoFactor.enforcement_status(user)
assert days >= 13 and days <= 14
end
test "user past grace window gets :enforced" do
old = DateTime.utc_now(:second) |> DateTime.add(-30, :day)
user = %GitGud.Accounts.User{confirmed_at: old, inserted_at: old}
assert TwoFactor.enforcement_status(user) == :enforced
end
test "enrolled user is :enrolled regardless of grace" do
old = DateTime.utc_now(:second) |> DateTime.add(-30, :day)
user = %GitGud.Accounts.User{
confirmed_at: old,
inserted_at: old,
totp_enabled_at: DateTime.utc_now(:second)
}
assert TwoFactor.enforcement_status(user) == :enrolled
end
test "without policy required: false, status is :not_required" do
Application.put_env(:git_gud, TwoFactor, required: false, grace_period_days: 14)
user = %GitGud.Accounts.User{confirmed_at: DateTime.utc_now(:second)}
assert TwoFactor.enforcement_status(user) == :not_required
end
end
end
6.4 KiB · text
5af55d9