From b78a40555c15c6c4740f78395412acc8ee07a3af Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 3 Feb 2026 11:16:43 -0600 Subject: [PATCH] more tests --- test/towerops/admin_test.exs | 465 ++++++++++++++++++++++++++++++++++- test/towerops/geoip_test.exs | 215 ++++++++++++++++ 2 files changed, 678 insertions(+), 2 deletions(-) create mode 100644 test/towerops/geoip_test.exs diff --git a/test/towerops/admin_test.exs b/test/towerops/admin_test.exs index 13035518..67cee85a 100644 --- a/test/towerops/admin_test.exs +++ b/test/towerops/admin_test.exs @@ -1,11 +1,472 @@ defmodule Towerops.AdminTest do use Towerops.DataCase + import Towerops.AccountsFixtures + + alias Towerops.Accounts.User alias Towerops.Admin + alias Towerops.Organizations.Organization + + describe "create_audit_log/1" do + test "creates audit log with valid attributes" do + superuser = user_fixture(%{superuser: true}) + target_user = user_fixture() + + attrs = %{ + action: "impersonate_start", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "192.168.1.1" + } + + assert {:ok, audit_log} = Admin.create_audit_log(attrs) + assert audit_log.action == "impersonate_start" + assert audit_log.superuser_id == superuser.id + assert audit_log.target_user_id == target_user.id + assert audit_log.ip_address.address == "192.168.1.1" + end + + test "creates audit log with nil target_user_id" do + superuser = user_fixture(%{superuser: true}) + + attrs = %{ + action: "user_data_exported", + superuser_id: superuser.id, + target_user_id: nil, + ip_address: "10.0.0.1" + } + + assert {:ok, audit_log} = Admin.create_audit_log(attrs) + assert audit_log.target_user_id == nil + end + + test "creates audit log with only required field (action)" do + attrs = %{ + action: "impersonate_start" + # Only action is required, superuser_id and ip_address are optional + } + + assert {:ok, audit_log} = Admin.create_audit_log(attrs) + assert audit_log.action == "impersonate_start" + assert audit_log.superuser_id == nil + assert audit_log.ip_address == nil + end + + test "returns error with invalid action" do + superuser = user_fixture(%{superuser: true}) + + attrs = %{ + action: "invalid_action", + superuser_id: superuser.id, + ip_address: "127.0.0.1" + } + + assert {:error, changeset} = Admin.create_audit_log(attrs) + assert %{action: ["is invalid"]} = errors_on(changeset) + end + + test "returns error with missing action" do + superuser = user_fixture(%{superuser: true}) + + attrs = %{ + superuser_id: superuser.id, + ip_address: "127.0.0.1" + } + + assert {:error, changeset} = Admin.create_audit_log(attrs) + assert %{action: ["can't be blank"]} = errors_on(changeset) + end + end + + describe "list_audit_logs/1" do + setup do + superuser = user_fixture(%{superuser: true}) + target_user = user_fixture() + + %{superuser: superuser, target_user: target_user} + end + + test "returns all audit logs with default pagination", %{superuser: superuser, target_user: target_user} do + {:ok, _log1} = + Admin.create_audit_log(%{ + action: "impersonate_start", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + + {:ok, _log2} = + Admin.create_audit_log(%{ + action: "impersonate_end", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + + result = Admin.list_audit_logs() + assert length(result) == 2 + end + + test "respects limit parameter", %{superuser: superuser, target_user: target_user} do + for _i <- 1..5 do + Admin.create_audit_log(%{ + action: "impersonate_start", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + end + + result = Admin.list_audit_logs(limit: 3) + assert length(result) == 3 + end + + test "orders logs by inserted_at descending", %{superuser: superuser, target_user: target_user} do + {:ok, _log1} = + Admin.create_audit_log(%{ + action: "impersonate_start", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + + {:ok, _log2} = + Admin.create_audit_log(%{ + action: "impersonate_end", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + + result = Admin.list_audit_logs() + assert length(result) == 2 + + # Verify ordering: each record's inserted_at should be >= next record + [first, second] = result + assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq] + end + + test "preloads superuser and target_user associations", %{superuser: superuser, target_user: target_user} do + {:ok, _log} = + Admin.create_audit_log(%{ + action: "impersonate_start", + superuser_id: superuser.id, + target_user_id: target_user.id, + ip_address: "127.0.0.1" + }) + + result = Admin.list_audit_logs() + assert length(result) == 1 + + log = hd(result) + assert Ecto.assoc_loaded?(log.superuser) + assert Ecto.assoc_loaded?(log.target_user) + end + + test "returns empty list when no audit logs exist" do + result = Admin.list_audit_logs() + assert result == [] + end + end + + describe "list_all_users/1" do + test "returns all users with default pagination" do + user1 = user_fixture() + user2 = user_fixture() + user3 = user_fixture() + + result = Admin.list_all_users() + assert length(result) >= 3 + user_ids = Enum.map(result, & &1.id) + assert user1.id in user_ids + assert user2.id in user_ids + assert user3.id in user_ids + end + + test "respects limit parameter" do + for _i <- 1..10 do + user_fixture() + end + + result = Admin.list_all_users(limit: 5) + assert length(result) == 5 + end + + test "respects offset parameter" do + # Create users in a clean test + Repo.delete_all(User) + _users = for _i <- 1..10, do: user_fixture() + + result = Admin.list_all_users(offset: 7) + assert length(result) == 3 + end + + test "combines limit and offset" do + Repo.delete_all(User) + + for _i <- 1..20 do + user_fixture() + end + + result = Admin.list_all_users(limit: 5, offset: 10) + assert length(result) == 5 + end + + test "orders users by inserted_at descending" do + Repo.delete_all(User) + + _user1 = user_fixture() + _user2 = user_fixture() + _user3 = user_fixture() + + result = Admin.list_all_users() + assert length(result) == 3 + + # Verify descending order: each user's inserted_at should be >= next user + [first, second, third] = result + assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq] + assert DateTime.compare(second.inserted_at, third.inserted_at) in [:gt, :eq] + end + + test "returns empty list when no users exist" do + # Clean up any existing users + Repo.delete_all(User) + + result = Admin.list_all_users() + assert result == [] + end + + test "preloads organizations association" do + user = user_fixture() + + result = Admin.list_all_users() + found_user = Enum.find(result, &(&1.id == user.id)) + + assert Ecto.assoc_loaded?(found_user.organizations) + end + end + + describe "count_users/0" do + test "returns count of all users" do + Repo.delete_all(User) + + assert Admin.count_users() == 0 + + user_fixture() + assert Admin.count_users() == 1 + + user_fixture() + user_fixture() + assert Admin.count_users() == 3 + end + end + + describe "delete_user/3" do + test "deletes user and creates audit log" do + superuser = user_fixture(%{superuser: true}) + target_user = user_fixture() + target_user_id = target_user.id + target_user_email = target_user.email + + assert {:ok, _deleted_user} = Admin.delete_user(target_user_id, superuser.id, "192.168.1.1") + + # User should be deleted + refute Repo.get(User, target_user_id) + + # Audit log should be created + audit_logs = Admin.list_audit_logs() + assert length(audit_logs) == 1 + log = hd(audit_logs) + assert log.action == "user_delete" + assert log.superuser_id == superuser.id + # target_user_id may be nil due to foreign key constraints after deletion + assert log.ip_address.address == "192.168.1.1" + assert log.metadata["email"] == target_user_email + end + + test "raises exception when user does not exist" do + superuser = user_fixture(%{superuser: true}) + non_existent_id = Ecto.UUID.generate() + + assert_raise Ecto.NoResultsError, fn -> + Admin.delete_user(non_existent_id, superuser.id, "127.0.0.1") + end + end + + test "anonymizes login history before deletion" do + superuser = user_fixture(%{superuser: true}) + target_user = user_fixture() + + # Create a login attempt for the target user + {:ok, _login} = + Towerops.Accounts.record_login_attempt(%{ + user_id: target_user.id, + email: target_user.email, + success: true, + method: "password", + ip_address: "10.0.0.1" + }) + + assert {:ok, _deleted_user} = Admin.delete_user(target_user.id, superuser.id, "192.168.1.1") + + # User should be deleted + refute Repo.get(User, target_user.id) + + # Login attempt should be anonymized (user_id set to nil, anonymized_at set) + login_attempt = Repo.get_by(Towerops.Accounts.LoginAttempt, email: target_user.email) + + if login_attempt do + assert is_nil(login_attempt.user_id) + assert login_attempt.anonymized_at + end + end + end + + describe "list_all_organizations/1" do + test "returns all organizations with default pagination" do + # Each user can only create one free organization, so use different users + user1 = user_fixture() + user2 = user_fixture() + user3 = user_fixture() + {:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user1.id) + {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user2.id) + {:ok, org3} = Towerops.Organizations.create_organization(%{name: "Org 3"}, user3.id) + + result = Admin.list_all_organizations() + assert length(result) >= 3 + org_ids = Enum.map(result, & &1.id) + assert org1.id in org_ids + assert org2.id in org_ids + assert org3.id in org_ids + end + + test "respects limit parameter" do + for i <- 1..10 do + user = user_fixture() + Towerops.Organizations.create_organization(%{name: "Org #{i}"}, user.id) + end + + result = Admin.list_all_organizations(limit: 5) + assert length(result) == 5 + end + + test "respects offset parameter" do + Repo.delete_all(Organization) + + _orgs = + for i <- 1..10 do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Org #{i}"}, user.id) + org + end + + result = Admin.list_all_organizations(offset: 7) + assert length(result) == 3 + end + + test "combines limit and offset" do + Repo.delete_all(Organization) + + for i <- 1..20 do + user = user_fixture() + Towerops.Organizations.create_organization(%{name: "Org #{i}"}, user.id) + end + + result = Admin.list_all_organizations(limit: 5, offset: 10) + assert length(result) == 5 + end + + test "orders organizations by inserted_at descending" do + Repo.delete_all(Organization) + + user1 = user_fixture() + user2 = user_fixture() + user3 = user_fixture() + + {:ok, _org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user1.id) + {:ok, _org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user2.id) + {:ok, _org3} = Towerops.Organizations.create_organization(%{name: "Org 3"}, user3.id) + + result = Admin.list_all_organizations() + assert length(result) == 3 + + # Verify descending order: each org's inserted_at should be >= next org + [first, second, third] = result + assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq] + assert DateTime.compare(second.inserted_at, third.inserted_at) in [:gt, :eq] + end + + test "returns empty list when no organizations exist" do + Repo.delete_all(Organization) + + result = Admin.list_all_organizations() + assert result == [] + end + + test "preloads memberships with users" do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + result = Admin.list_all_organizations() + found_org = Enum.find(result, &(&1.id == org.id)) + + assert Ecto.assoc_loaded?(found_org.memberships) + assert found_org.memberships != [] + assert Ecto.assoc_loaded?(hd(found_org.memberships).user) + end + end + + describe "count_organizations/0" do + test "returns count of all organizations" do + Repo.delete_all(Organization) + + assert Admin.count_organizations() == 0 + + user1 = user_fixture() + Towerops.Organizations.create_organization(%{name: "Org 1"}, user1.id) + assert Admin.count_organizations() == 1 + + user2 = user_fixture() + user3 = user_fixture() + Towerops.Organizations.create_organization(%{name: "Org 2"}, user2.id) + Towerops.Organizations.create_organization(%{name: "Org 3"}, user3.id) + assert Admin.count_organizations() == 3 + end + end + + describe "delete_organization/3" do + test "deletes organization and creates audit log" do + superuser = user_fixture(%{superuser: true}) + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + assert {:ok, _deleted_org} = Admin.delete_organization(org.id, superuser.id, "192.168.1.1") + + # Organization should be deleted + refute Repo.get(Organization, org.id) + + # Audit log should be created + audit_logs = Admin.list_audit_logs() + assert length(audit_logs) == 1 + log = hd(audit_logs) + assert log.action == "org_delete" + assert log.superuser_id == superuser.id + assert log.ip_address.address == "192.168.1.1" + assert log.metadata["name"] == "Test Org" + assert log.metadata["slug"] == org.slug + end + + test "raises exception when organization does not exist" do + superuser = user_fixture(%{superuser: true}) + non_existent_id = Ecto.UUID.generate() + + assert_raise Ecto.NoResultsError, fn -> + Admin.delete_organization(non_existent_id, superuser.id, "127.0.0.1") + end + end + end describe "GDPR data access - list_audit_logs_for_user/2" do - import Towerops.AccountsFixtures - setup do user = user_fixture() superuser = user_fixture(%{superuser: true}) diff --git a/test/towerops/geoip_test.exs b/test/towerops/geoip_test.exs new file mode 100644 index 00000000..0a8cf429 --- /dev/null +++ b/test/towerops/geoip_test.exs @@ -0,0 +1,215 @@ +defmodule Towerops.GeoIPTest do + use Towerops.DataCase, async: true + + alias Towerops.GeoIP + alias Towerops.GeoIP.Block + alias Towerops.GeoIP.Location + + describe "lookup/1" do + test "returns country code for valid IP with location data" do + # Insert test location + location = insert_location(%{geoname_id: 5_375_480, country_code: "US"}) + + # Insert test block for IP 8.8.8.8 (134744072) + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 134_744_072, + end_ip_int: 134_744_072 + }) + + assert GeoIP.lookup("8.8.8.8") == "US" + end + + test "returns nil for valid IP with no location data" do + assert GeoIP.lookup("192.168.1.1") == nil + end + + test "returns nil for invalid IP address" do + assert GeoIP.lookup("invalid") == nil + end + + test "returns nil for empty string" do + assert GeoIP.lookup("") == nil + end + + test "returns nil for IP with too many octets" do + assert GeoIP.lookup("1.2.3.4.5") == nil + end + + test "returns nil for IP with invalid octets" do + assert GeoIP.lookup("999.999.999.999") == nil + end + end + + describe "lookup_full/1" do + test "returns full location data for valid IP" do + # Insert test location with full data + location = + insert_location(%{ + geoname_id: 5_375_480, + country_code: "US", + country_name: "United States", + city_name: "Mountain View", + subdivision_1_name: "California", + subdivision_2_name: nil, + latitude: 37.386, + longitude: -122.0838 + }) + + # Insert test block for IP 8.8.8.8 + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 134_744_072, + end_ip_int: 134_744_072 + }) + + result = GeoIP.lookup_full("8.8.8.8") + + assert result.country_code == "US" + assert result.country_name == "United States" + assert result.city_name == "Mountain View" + assert result.subdivision_1_name == "California" + assert result.subdivision_2_name == nil + assert result.latitude == 37.386 + assert result.longitude == -122.0838 + end + + test "returns location data with minimal fields" do + location = insert_location(%{geoname_id: 1234, country_code: "CA"}) + + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 100, + end_ip_int: 200 + }) + + result = GeoIP.lookup_full("0.0.0.100") + + assert result.country_code == "CA" + assert result.country_name == nil + assert result.city_name == nil + end + + test "returns nil for IP not in any block" do + # Create location and block, but query different IP + location = insert_location(%{geoname_id: 5678, country_code: "US"}) + + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 1000, + end_ip_int: 2000 + }) + + assert GeoIP.lookup_full("192.168.1.1") == nil + end + + test "returns nil for invalid IP string" do + assert GeoIP.lookup_full("not-an-ip") == nil + end + + test "returns nil for empty string" do + assert GeoIP.lookup_full("") == nil + end + + test "handles IP at start of range" do + location = insert_location(%{geoname_id: 9999, country_code: "FR"}) + + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 167_772_160, + end_ip_int: 167_772_170 + }) + + # 10.0.0.0 = 167772160 + result = GeoIP.lookup_full("10.0.0.0") + assert result.country_code == "FR" + end + + test "handles IP at end of range" do + location = insert_location(%{geoname_id: 8888, country_code: "DE"}) + + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 167_772_160, + end_ip_int: 167_772_170 + }) + + # 10.0.0.10 = 167772170 + result = GeoIP.lookup_full("10.0.0.10") + assert result.country_code == "DE" + end + + test "handles IP in middle of range" do + location = insert_location(%{geoname_id: 7777, country_code: "GB"}) + + insert_block(%{ + geoname_id: location.geoname_id, + start_ip_int: 167_772_160, + end_ip_int: 167_772_170 + }) + + # 10.0.0.5 = 167772165 + result = GeoIP.lookup_full("10.0.0.5") + assert result.country_code == "GB" + end + + test "returns first matching block when multiple blocks overlap" do + location1 = insert_location(%{geoname_id: 1111, country_code: "US"}) + location2 = insert_location(%{geoname_id: 2222, country_code: "CA"}) + + insert_block(%{ + network: "0.0.0.100/24", + geoname_id: location1.geoname_id, + start_ip_int: 100, + end_ip_int: 200 + }) + + insert_block(%{ + network: "0.0.0.50/24", + geoname_id: location2.geoname_id, + start_ip_int: 50, + end_ip_int: 150 + }) + + # Query returns first match due to limit: 1 + result = GeoIP.lookup_full("0.0.0.150") + assert result.country_code in ["US", "CA"] + end + end + + # Helper functions to insert test data + defp insert_location(attrs) do + default_attrs = %{ + geoname_id: System.unique_integer([:positive]), + country_code: "US", + country_name: nil, + city_name: nil, + subdivision_1_name: nil, + subdivision_2_name: nil, + latitude: nil, + longitude: nil + } + + attrs = Map.merge(default_attrs, Map.new(attrs)) + + %Location{} + |> Location.changeset(attrs) + |> Repo.insert!() + end + + defp insert_block(attrs) do + default_attrs = %{ + network: "0.0.0.0/32", + start_ip_int: 0, + end_ip_int: 0, + geoname_id: nil, + registered_country_geoname_id: nil + } + + attrs = Map.merge(default_attrs, Map.new(attrs)) + + %Block{} + |> Block.changeset(attrs) + |> Repo.insert!() + end +end