From 7e5da340d3b3aa03d65dc898fa89965b85b65d85 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 13 Jan 2026 07:47:37 -0600 Subject: [PATCH] Add comprehensive tests for Accounts.UserCredential Improved coverage from 28.57% to 100% by adding 13 tests covering: - Required field validations (user_id, credential_id, public_key, name) - Name length validation (min 1, max 100 characters) - Optional field handling (sign_count, last_used_at, aaguid, transports, backup flags, attestation_format) - Default values for optional fields - touch_changeset/1 function (updates last_used_at and increments sign_count) Overall coverage: 66.44% --- .../accounts/user_credential_test.exs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 test/towerops/accounts/user_credential_test.exs diff --git a/test/towerops/accounts/user_credential_test.exs b/test/towerops/accounts/user_credential_test.exs new file mode 100644 index 00000000..25191446 --- /dev/null +++ b/test/towerops/accounts/user_credential_test.exs @@ -0,0 +1,200 @@ +defmodule Towerops.Accounts.UserCredentialTest do + use Towerops.DataCase + + alias Towerops.Accounts.UserCredential + + describe "changeset/2" do + test "valid changeset with required fields" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: "My Passkey" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + assert changeset.valid? + assert get_field(changeset, :name) == "My Passkey" + assert get_field(changeset, :credential_id) == attrs.credential_id + assert get_field(changeset, :public_key) == attrs.public_key + end + + test "requires user_id" do + attrs = %{ + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: "My Passkey" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + assert %{user_id: ["can't be blank"]} = errors_on(changeset) + end + + test "requires credential_id" do + attrs = %{ + user_id: Ecto.UUID.generate(), + public_key: :crypto.strong_rand_bytes(65), + name: "My Passkey" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + assert %{credential_id: ["can't be blank"]} = errors_on(changeset) + end + + test "requires public_key" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + name: "My Passkey" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + assert %{public_key: ["can't be blank"]} = errors_on(changeset) + end + + test "requires name" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65) + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + assert %{name: ["can't be blank"]} = errors_on(changeset) + end + + test "validates name length minimum" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: "" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + # Empty string triggers "can't be blank" before length validation + assert %{name: ["can't be blank"]} = errors_on(changeset) + end + + test "validates name length maximum" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: String.duplicate("a", 101) + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + refute changeset.valid? + assert %{name: ["should be at most 100 character(s)"]} = errors_on(changeset) + end + + test "accepts name at maximum length" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: String.duplicate("a", 100) + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + assert changeset.valid? + end + + test "accepts optional fields" do + last_used_at = DateTime.truncate(DateTime.utc_now(), :second) + + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: "My Passkey", + sign_count: 42, + last_used_at: last_used_at, + aaguid: :crypto.strong_rand_bytes(16), + transports: ["usb", "nfc"], + backup_eligible: true, + backup_state: true, + attestation_format: "packed" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + assert changeset.valid? + assert get_field(changeset, :sign_count) == 42 + assert DateTime.compare(get_field(changeset, :last_used_at), last_used_at) == :eq + assert get_field(changeset, :aaguid) == attrs.aaguid + assert get_field(changeset, :transports) == ["usb", "nfc"] + assert get_field(changeset, :backup_eligible) == true + assert get_field(changeset, :backup_state) == true + assert get_field(changeset, :attestation_format) == "packed" + end + + test "uses default values for optional fields when not provided" do + attrs = %{ + user_id: Ecto.UUID.generate(), + credential_id: :crypto.strong_rand_bytes(16), + public_key: :crypto.strong_rand_bytes(65), + name: "My Passkey" + } + + changeset = UserCredential.changeset(%UserCredential{}, attrs) + + assert changeset.valid? + # These defaults come from the schema definition + assert get_field(changeset, :sign_count) == 0 + assert get_field(changeset, :transports) == [] + assert get_field(changeset, :backup_eligible) == false + assert get_field(changeset, :backup_state) == false + end + end + + describe "touch_changeset/1" do + test "updates last_used_at to current time" do + old_time = DateTime.add(DateTime.utc_now(), -3600, :second) + + credential = %UserCredential{ + sign_count: 5, + last_used_at: old_time + } + + changeset = UserCredential.touch_changeset(credential) + last_used_at = get_field(changeset, :last_used_at) + + # Should be more recent than the old time + assert DateTime.after?(last_used_at, old_time) + # Should be within last 2 seconds (account for timing + truncation) + diff = DateTime.diff(DateTime.utc_now(), last_used_at, :second) + assert diff <= 2 + end + + test "increments sign_count by 1" do + credential = %UserCredential{sign_count: 5} + + changeset = UserCredential.touch_changeset(credential) + + assert get_field(changeset, :sign_count) == 6 + end + + test "increments sign_count from 0" do + credential = %UserCredential{sign_count: 0} + + changeset = UserCredential.touch_changeset(credential) + + assert get_field(changeset, :sign_count) == 1 + end + end +end