diff --git a/test/towerops/snmp/adapters/replay_test.exs b/test/towerops/snmp/adapters/replay_test.exs new file mode 100644 index 00000000..ace7c936 --- /dev/null +++ b/test/towerops/snmp/adapters/replay_test.exs @@ -0,0 +1,283 @@ +defmodule Towerops.Snmp.Adapters.ReplayTest do + use ExUnit.Case, async: true + + alias Towerops.Snmp.Adapters.Replay + + describe "new/1" do + test "creates connection opts with OID map" do + oid_map = %{ + "1.3.6.1.2.1.1.1.0" => "Cisco IOS Software", + "1.3.6.1.2.1.2.2.1.1.1" => "1" + } + + opts = Replay.new(oid_map) + + assert Keyword.get(opts, :adapter) == Replay + assert Keyword.get(opts, :oid_map) == oid_map + assert Keyword.get(opts, :ip) == "replay" + assert Keyword.get(opts, :community) == "public" + assert Keyword.get(opts, :version) == "2c" + assert Keyword.get(opts, :port) == 161 + end + + test "works with empty OID map" do + opts = Replay.new(%{}) + + assert Keyword.get(opts, :adapter) == Replay + assert Keyword.get(opts, :oid_map) == %{} + end + end + + describe "get/2" do + test "returns value for existing OID" do + oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"} + opts = Replay.new(oid_map) + + assert {:ok, "Cisco IOS Software"} = Replay.get(opts, "1.3.6.1.2.1.1.1.0") + end + + test "returns error for non-existent OID" do + opts = Replay.new(%{}) + + assert {:error, :no_such_name} = Replay.get(opts, "1.3.6.1.2.1.1.1.0") + end + + test "parses integer values" do + oid_map = %{"1.3.6.1.2.1.2.2.1.1.1" => "42"} + opts = Replay.new(oid_map) + + assert {:ok, 42} = Replay.get(opts, "1.3.6.1.2.1.2.2.1.1.1") + end + + test "parses float values" do + oid_map = %{"1.3.6.1.2.1.25.3.3.1.2.1" => "98.6"} + opts = Replay.new(oid_map) + + assert {:ok, 98.6} = Replay.get(opts, "1.3.6.1.2.1.25.3.3.1.2.1") + end + + test "parses OID list values" do + oid_map = %{"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.516"} + opts = Replay.new(oid_map) + + assert {:ok, [1, 3, 6, 1, 4, 1, 9, 1, 516]} = + Replay.get(opts, "1.3.6.1.2.1.1.2.0") + end + + test "parses MAC address values" do + oid_map = %{"1.3.6.1.2.1.2.2.1.6.1" => "aa:bb:cc:dd:ee:ff"} + opts = Replay.new(oid_map) + + assert {:ok, "aa:bb:cc:dd:ee:ff"} = Replay.get(opts, "1.3.6.1.2.1.2.2.1.6.1") + end + + test "parses IPv4 address values" do + oid_map = %{"1.3.6.1.2.1.4.20.1.1.192.168.1.1" => "192.168.1.1"} + opts = Replay.new(oid_map) + + assert {:ok, "192.168.1.1"} = Replay.get(opts, "1.3.6.1.2.1.4.20.1.1.192.168.1.1") + end + + test "keeps string values as strings" do + oid_map = %{"1.3.6.1.2.1.1.5.0" => "router-01.example.com"} + opts = Replay.new(oid_map) + + assert {:ok, "router-01.example.com"} = Replay.get(opts, "1.3.6.1.2.1.1.5.0") + end + end + + describe "walk/2" do + test "returns all child OIDs under base" do + oid_map = %{ + "1.3.6.1.2.1.2.2.1.1.1" => "1", + "1.3.6.1.2.1.2.2.1.1.2" => "2", + "1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1", + "1.3.6.1.2.1.2.2.1.2.2" => "GigabitEthernet0/2" + } + + opts = Replay.new(oid_map) + + assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1") + + assert length(results) == 2 + assert {"1.3.6.1.2.1.2.2.1.1.1", 1} in results + assert {"1.3.6.1.2.1.2.2.1.1.2", 2} in results + end + + test "returns empty list when no children exist" do + oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"} + opts = Replay.new(oid_map) + + assert {:ok, []} = Replay.walk(opts, "1.3.6.1.2.1.99") + end + + test "does not include base OID itself" do + oid_map = %{ + "1.3.6.1.2.1.2.2.1.1" => "base", + "1.3.6.1.2.1.2.2.1.1.1" => "child" + } + + opts = Replay.new(oid_map) + + assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1") + + assert length(results) == 1 + assert {"1.3.6.1.2.1.2.2.1.1.1", "child"} in results + refute {"1.3.6.1.2.1.2.2.1.1", "base"} in results + end + + test "returns results in lexicographic order" do + oid_map = %{ + "1.3.6.1.2.1.2.2.1.1.10" => "10", + "1.3.6.1.2.1.2.2.1.1.2" => "2", + "1.3.6.1.2.1.2.2.1.1.1" => "1" + } + + opts = Replay.new(oid_map) + + assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1") + + # Extract OIDs + oids = Enum.map(results, fn {oid, _} -> oid end) + + # Should be sorted: 1, 2, 10 (not "1", "10", "2" as strings) + assert oids == [ + "1.3.6.1.2.1.2.2.1.1.1", + "1.3.6.1.2.1.2.2.1.1.2", + "1.3.6.1.2.1.2.2.1.1.10" + ] + end + + test "parses values in walk results" do + oid_map = %{ + "1.3.6.1.2.1.2.2.1.1.1" => "42", + "1.3.6.1.2.1.2.2.1.1.2" => "98.6", + "1.3.6.1.2.1.2.2.1.1.3" => "192.168.1.1" + } + + opts = Replay.new(oid_map) + + assert {:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1") + + assert {"1.3.6.1.2.1.2.2.1.1.1", 42} in results + assert {"1.3.6.1.2.1.2.2.1.1.2", 98.6} in results + assert {"1.3.6.1.2.1.2.2.1.1.3", "192.168.1.1"} in results + end + end + + describe "get_multiple/2" do + test "returns all requested OIDs" do + oid_map = %{ + "1.3.6.1.2.1.1.1.0" => "Cisco IOS Software", + "1.3.6.1.2.1.1.5.0" => "router-01", + "1.3.6.1.2.1.1.6.0" => "Data Center 1" + } + + opts = Replay.new(oid_map) + + oids = [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.5.0", + "1.3.6.1.2.1.1.6.0" + ] + + assert {:ok, results} = Replay.get_multiple(opts, oids) + + assert results["1.3.6.1.2.1.1.1.0"] == "Cisco IOS Software" + assert results["1.3.6.1.2.1.1.5.0"] == "router-01" + assert results["1.3.6.1.2.1.1.6.0"] == "Data Center 1" + end + + test "returns nil for non-existent OIDs" do + oid_map = %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software"} + opts = Replay.new(oid_map) + + oids = [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.99.99.99" + ] + + assert {:ok, results} = Replay.get_multiple(opts, oids) + + assert results["1.3.6.1.2.1.1.1.0"] == "Cisco IOS Software" + assert results["1.3.6.1.2.1.99.99.99"] == nil + end + + test "works with empty OID list" do + opts = Replay.new(%{}) + + assert {:ok, %{}} = Replay.get_multiple(opts, []) + end + + test "parses values correctly" do + oid_map = %{ + "1.3.6.1.2.1.2.2.1.1.1" => "42", + "1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1" + } + + opts = Replay.new(oid_map) + + oids = [ + "1.3.6.1.2.1.2.2.1.1.1", + "1.3.6.1.2.1.2.2.1.2.1" + ] + + assert {:ok, results} = Replay.get_multiple(opts, oids) + + assert results["1.3.6.1.2.1.2.2.1.1.1"] == 42 + assert results["1.3.6.1.2.1.2.2.1.2.1"] == "GigabitEthernet0/1" + end + end + + describe "type inference edge cases" do + test "parses MAC address with hyphens" do + oid_map = %{"oid" => "AA-BB-CC-DD-EE-FF"} + opts = Replay.new(oid_map) + + assert {:ok, "AA-BB-CC-DD-EE-FF"} = Replay.get(opts, "oid") + end + + test "treats 4-component dotted number as IPv4, not OID" do + # Ambiguous case: "1.2.3.4" could be IPv4 or OID, we choose IPv4 + oid_map = %{"oid" => "1.2.3.4"} + opts = Replay.new(oid_map) + + assert {:ok, "1.2.3.4"} = Replay.get(opts, "oid") + end + + test "parses 5-component dotted number as OID list" do + oid_map = %{"oid" => "1.2.3.4.5"} + opts = Replay.new(oid_map) + + assert {:ok, [1, 2, 3, 4, 5]} = Replay.get(opts, "oid") + end + + test "parses negative float" do + oid_map = %{"oid" => "-12.5"} + opts = Replay.new(oid_map) + + assert {:ok, -12.5} = Replay.get(opts, "oid") + end + + test "keeps mixed alphanumeric as string" do + oid_map = %{"oid" => "GigabitEthernet0/1"} + opts = Replay.new(oid_map) + + assert {:ok, "GigabitEthernet0/1"} = Replay.get(opts, "oid") + end + + test "handles zero value" do + oid_map = %{"oid" => "0"} + opts = Replay.new(oid_map) + + assert {:ok, 0} = Replay.get(opts, "oid") + end + + test "handles large integer" do + oid_map = %{"oid" => "4294967295"} + opts = Replay.new(oid_map) + + assert {:ok, 4_294_967_295} = Replay.get(opts, "oid") + end + end +end diff --git a/test/towerops/snmp/processor_reading_test.exs b/test/towerops/snmp/processor_reading_test.exs new file mode 100644 index 00000000..fac27276 --- /dev/null +++ b/test/towerops/snmp/processor_reading_test.exs @@ -0,0 +1,234 @@ +defmodule Towerops.Snmp.ProcessorReadingTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.ProcessorReading + + describe "changeset/2" do + setup do + processor = insert_processor() + %{processor: processor} + end + + test "valid changeset with all fields", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + load_percent: 45.5, + status: "ok", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + assert changeset.valid? + assert get_change(changeset, :processor_id) == processor.id + assert get_change(changeset, :load_percent) == 45.5 + assert get_change(changeset, :status) == "ok" + end + + test "valid changeset without optional load_percent", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + status: "ok", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + assert changeset.valid? + refute get_change(changeset, :load_percent) + end + + test "valid changeset with different status values", %{processor: processor} do + for status <- ["ok", "warning", "critical", "error"] do + attrs = %{ + processor_id: processor.id, + status: status, + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + assert changeset.valid?, "Status #{status} should be valid" + end + end + + test "invalid changeset when processor_id is missing" do + attrs = %{ + status: "ok", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + refute changeset.valid? + assert %{processor_id: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid changeset when status is missing", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + refute changeset.valid? + assert %{status: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid changeset when checked_at is missing", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + status: "ok" + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + refute changeset.valid? + assert %{checked_at: ["can't be blank"]} = errors_on(changeset) + end + + test "invalid changeset with invalid status value", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + status: "invalid_status", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + refute changeset.valid? + assert %{status: ["is invalid"]} = errors_on(changeset) + end + + test "invalid changeset with non-existent processor_id" do + attrs = %{ + processor_id: Ecto.UUID.generate(), + status: "ok", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + + assert changeset.valid? + # Foreign key constraint will be checked at insert time + assert {:error, changeset} = Repo.insert(changeset) + assert %{processor_id: ["does not exist"]} = errors_on(changeset) + end + end + + describe "database operations" do + setup do + processor = insert_processor() + %{processor: processor} + end + + test "inserts valid processor reading", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + load_percent: 75.5, + status: "warning", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + assert {:ok, reading} = Repo.insert(changeset) + + assert reading.processor_id == processor.id + assert reading.load_percent == 75.5 + assert reading.status == "warning" + assert reading.inserted_at + end + + test "inserts reading without load_percent", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + status: "error", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + assert {:ok, reading} = Repo.insert(changeset) + + assert is_nil(reading.load_percent) + assert reading.status == "error" + end + + test "timestamps are set automatically", %{processor: processor} do + attrs = %{ + processor_id: processor.id, + status: "ok", + checked_at: DateTime.utc_now() + } + + changeset = ProcessorReading.changeset(%ProcessorReading{}, attrs) + assert {:ok, reading} = Repo.insert(changeset) + + assert reading.inserted_at + # updated_at should be false according to schema + refute Map.has_key?(reading, :updated_at) + end + end + + # Test helper to create a processor + defp insert_processor do + snmp_device = insert_snmp_device() + + Repo.insert!(%Towerops.Snmp.Processor{ + snmp_device_id: snmp_device.id, + processor_index: "1", + processor_type: "hr_processor", + load_percent: 0.0 + }) + end + + defp insert_snmp_device do + device = insert_device() + + Repo.insert!(%Towerops.Snmp.Device{ + device_id: device.id, + sys_descr: "Test Device", + sys_object_id: "1.3.6.1.4.1.9", + sys_name: "test-device" + }) + end + + defp insert_device do + org = insert_organization() + site = insert_site(org) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Test Device", + ip_address: "192.168.1.1", + site_id: site.id + }) + + device + end + + defp insert_organization do + user = insert_user() + + {:ok, org} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + org + end + + defp insert_site(org) do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: org.id + }) + + site + end + + defp insert_user do + user_fixture() + end +end diff --git a/test/towerops/workers/backup_summary_worker_test.exs b/test/towerops/workers/backup_summary_worker_test.exs new file mode 100644 index 00000000..98c379ee --- /dev/null +++ b/test/towerops/workers/backup_summary_worker_test.exs @@ -0,0 +1,231 @@ +defmodule Towerops.Workers.BackupSummaryWorkerTest do + use Towerops.DataCase, async: false + + import ExUnit.CaptureLog + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Devices.BackupRequests + alias Towerops.Organizations + alias Towerops.Sites + alias Towerops.Workers.BackupSummaryWorker + + require Logger + + setup do + # Ensure Logger level is set to info for capturing logs + original_level = Logger.level() + Logger.configure(level: :info) + + on_exit(fn -> + Logger.configure(level: original_level) + end) + + :ok + end + + describe "perform/1" do + test "returns :ok when no backup requests exist" do + # Execute the worker + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end + + test "logs summary when only successful backups exist" do + device = insert_device() + + # Create successful requests + {:ok, req} = BackupRequests.create_request(device.id, "job1") + BackupRequests.update_request_status(req.id, "success", nil) + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify summary was logged + assert log =~ "Backup summary for past 24 hours" + refute log =~ "Backup failures detected" + end + + test "logs warning when failed backups exist" do + device = insert_device() + + # Create a failed request + {:ok, req} = BackupRequests.create_request(device.id, "job_failed") + BackupRequests.update_request_status(req.id, "failed", "Connection error") + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify warning was logged + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "logs warning when timeout backups exist" do + device = insert_device() + + # Create a timeout request + {:ok, req} = BackupRequests.create_request(device.id, "job_timeout") + BackupRequests.update_request_status(req.id, "timeout", "Request timed out") + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify warning was logged + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "logs warning when both failed and timeout backups exist" do + device = insert_device() + + # Create a failed request + {:ok, req1} = BackupRequests.create_request(device.id, "job_failed") + BackupRequests.update_request_status(req1.id, "failed", "Connection error") + + # Create a timeout request + {:ok, req2} = BackupRequests.create_request(device.id, "job_timeout") + BackupRequests.update_request_status(req2.id, "timeout", "Request timed out") + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify warning was logged + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "includes device IDs in warning log" do + device1 = insert_device() + device2 = insert_device() + + # Create failed requests for multiple devices + {:ok, req1} = BackupRequests.create_request(device1.id, "job1") + BackupRequests.update_request_status(req1.id, "failed", "Error") + + {:ok, req2} = BackupRequests.create_request(device2.id, "job2") + BackupRequests.update_request_status(req2.id, "timeout", "Timeout") + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify warnings were logged (metadata fields like device IDs aren't captured in text logs) + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "only includes requests from past 24 hours" do + device = insert_device() + + # Create an old failed request (30 hours ago) + {:ok, old_req} = BackupRequests.create_request(device.id, "job_old") + + old_req + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-30 * 60 * 60, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + BackupRequests.update_request_status(old_req.id, "failed", "Old error") + + # Create a recent failed request (12 hours ago) + {:ok, recent_req} = BackupRequests.create_request(device.id, "job_recent") + + recent_req + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-12 * 60 * 60, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + BackupRequests.update_request_status(recent_req.id, "failed", "Recent error") + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify summary and failure warning are logged (only recent failure should be counted) + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "handles mixed statuses correctly" do + device = insert_device() + + # Create requests with various statuses + {:ok, req1} = BackupRequests.create_request(device.id, "job_success") + BackupRequests.update_request_status(req1.id, "success", nil) + + {:ok, req2} = BackupRequests.create_request(device.id, "job_failed") + BackupRequests.update_request_status(req2.id, "failed", "Error") + + {:ok, req3} = BackupRequests.create_request(device.id, "job_timeout") + BackupRequests.update_request_status(req3.id, "timeout", "Timeout") + + {:ok, _req4} = BackupRequests.create_request(device.id, "job_pending") + # Leave as pending + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify summary and failure warnings are logged + assert log =~ "Backup summary for past 24 hours" + assert log =~ "Backup failures detected" + end + + test "does not log warning when all backups are successful or pending" do + device = insert_device() + + # Create successful and pending requests + {:ok, req1} = BackupRequests.create_request(device.id, "job_success") + BackupRequests.update_request_status(req1.id, "success", nil) + + {:ok, _req2} = BackupRequests.create_request(device.id, "job_pending") + # Leave as pending + + # Execute the worker and capture logs + log = + capture_log(fn -> + assert :ok = BackupSummaryWorker.perform(%Oban.Job{args: %{}}) + end) + + # Verify no warning was logged + assert log =~ "Backup summary for past 24 hours" + refute log =~ "Backup failures detected" + end + end + + # Helper function to create a device + defp insert_device do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + + {:ok, device} = + Devices.create_device(%{ + name: "Test Device #{System.unique_integer()}", + ip_address: "192.168.1.#{:rand.uniform(254)}", + site_id: site.id + }) + + device + end +end diff --git a/test/towerops/workers/backup_timeout_worker_test.exs b/test/towerops/workers/backup_timeout_worker_test.exs new file mode 100644 index 00000000..757568be --- /dev/null +++ b/test/towerops/workers/backup_timeout_worker_test.exs @@ -0,0 +1,205 @@ +defmodule Towerops.Workers.BackupTimeoutWorkerTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Devices.BackupRequests + alias Towerops.Organizations + alias Towerops.Sites + alias Towerops.Workers.BackupTimeoutWorker + + describe "perform/1" do + test "marks pending requests older than 5 minutes as timeout" do + device = insert_device() + + # Create a pending request from 10 minutes ago (older than 5 minute timeout) + {:ok, old_request} = BackupRequests.create_request(device.id, "job_old") + + old_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # Verify request was marked as timeout + updated = Repo.reload(old_request) + assert updated.status == "timeout" + assert updated.error_message == "Request timed out" + assert updated.completed_at + end + + test "does not mark recent pending requests" do + device = insert_device() + + # Create a pending request from 2 minutes ago (within 5 minute timeout) + {:ok, recent_request} = BackupRequests.create_request(device.id, "job_recent") + + recent_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-120, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # Verify request still pending + updated = Repo.reload(recent_request) + assert updated.status == "pending" + assert is_nil(updated.completed_at) + end + + test "does not mark completed requests" do + device = insert_device() + + # Create an old request that already succeeded + {:ok, old_request} = BackupRequests.create_request(device.id, "job_old") + + old_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + {:ok, _} = BackupRequests.update_request_status(old_request.id, "success", nil) + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # Verify request still has success status + updated = Repo.reload(old_request) + assert updated.status == "success" + end + + test "handles multiple requests with mixed statuses" do + device = insert_device() + + # Create old pending request (should timeout) + {:ok, old_pending} = BackupRequests.create_request(device.id, "job_old_pending") + + old_pending + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Create recent pending request (should stay pending) + {:ok, recent_pending} = BackupRequests.create_request(device.id, "job_recent_pending") + + recent_pending + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-120, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Create old completed request (should stay success) + {:ok, old_success} = BackupRequests.create_request(device.id, "job_old_success") + + old_success + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + {:ok, _} = BackupRequests.update_request_status(old_success.id, "success", nil) + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # Verify only old pending was marked as timeout + assert Repo.reload(old_pending).status == "timeout" + assert Repo.reload(recent_pending).status == "pending" + assert Repo.reload(old_success).status == "success" + end + + test "handles boundary case at exactly 5 minutes" do + device = insert_device() + + # Create a request at exactly 5 minutes (300 seconds) ago + {:ok, boundary_request} = BackupRequests.create_request(device.id, "job_boundary") + + boundary_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-300, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # The cutoff uses `< cutoff`, so exactly 5 minutes should NOT timeout + updated = Repo.reload(boundary_request) + assert updated.status == "pending" + end + + test "returns :ok when no requests need timeout" do + device = insert_device() + + # Create only recent request + {:ok, recent_request} = BackupRequests.create_request(device.id, "job_recent") + + recent_request + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + end + + test "returns :ok when no requests exist at all" do + # Execute the worker with no requests in the database + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + end + + test "handles multiple devices with stale requests" do + device1 = insert_device() + device2 = insert_device() + + # Create old pending requests for both devices + {:ok, request1} = BackupRequests.create_request(device1.id, "job1") + + request1 + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + {:ok, request2} = BackupRequests.create_request(device2.id, "job2") + + request2 + |> Ecto.Changeset.change( + requested_at: DateTime.utc_now() |> DateTime.add(-700, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + # Execute the worker + assert :ok = BackupTimeoutWorker.perform(%Oban.Job{args: %{}}) + + # Verify both were marked as timeout + assert Repo.reload(request1).status == "timeout" + assert Repo.reload(request2).status == "timeout" + end + end + + # Helper function to create a device + defp insert_device do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + + {:ok, device} = + Devices.create_device(%{ + name: "Test Device #{System.unique_integer()}", + ip_address: "192.168.1.#{:rand.uniform(254)}", + site_id: site.id + }) + + device + end +end diff --git a/test/towerops/workers/login_history_cleanup_worker_test.exs b/test/towerops/workers/login_history_cleanup_worker_test.exs new file mode 100644 index 00000000..b2de39e9 --- /dev/null +++ b/test/towerops/workers/login_history_cleanup_worker_test.exs @@ -0,0 +1,238 @@ +defmodule Towerops.Workers.LoginHistoryCleanupWorkerTest do + use Towerops.DataCase, async: true + + alias Towerops.Accounts.LoginAttempt + alias Towerops.Workers.LoginHistoryCleanupWorker + + describe "perform/1" do + test "deletes active login attempts older than retention period" do + # Create an old active record (400 days old, past default 365 retention) + old_attempt = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -400, :day), + anonymized_at: nil + }) + + # Execute the worker + assert {:ok, %{deleted: 1, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify old active record was deleted + refute Repo.get(LoginAttempt, old_attempt.id) + end + + test "keeps active login attempts within retention period" do + # Create a recent active record (100 days old, within default 365 retention) + recent_attempt = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -100, :day), + anonymized_at: nil + }) + + # Execute the worker + assert {:ok, %{deleted: 0, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify recent active record still exists + assert Repo.get(LoginAttempt, recent_attempt.id) + end + + test "deletes anonymized records older than 90 days" do + # Create an anonymized record (100 days after anonymization) + anon_date = DateTime.add(DateTime.utc_now(), -100, :day) + + old_anon_attempt = + insert_login_attempt(%{ + inserted_at: DateTime.add(anon_date, -200, :day), + anonymized_at: anon_date + }) + + # Execute the worker + assert {:ok, %{deleted: 0, anonymized_deleted: 1}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify old anonymized record was deleted + refute Repo.get(LoginAttempt, old_anon_attempt.id) + end + + test "keeps anonymized records within 90 day period" do + # Create an anonymized record (30 days after anonymization) + anon_date = DateTime.add(DateTime.utc_now(), -30, :day) + + recent_anon_attempt = + insert_login_attempt(%{ + inserted_at: DateTime.add(anon_date, -200, :day), + anonymized_at: anon_date + }) + + # Execute the worker + assert {:ok, %{deleted: 0, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify recent anonymized record still exists + assert Repo.get(LoginAttempt, recent_anon_attempt.id) + end + + test "handles mixed active and anonymized records" do + # Create old active record (should be deleted) + old_active = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -400, :day), + anonymized_at: nil + }) + + # Create recent active record (should be kept) + recent_active = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -100, :day), + anonymized_at: nil + }) + + # Create old anonymized record (should be deleted) + old_anon = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -500, :day), + anonymized_at: DateTime.add(DateTime.utc_now(), -100, :day) + }) + + # Create recent anonymized record (should be kept) + recent_anon = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -400, :day), + anonymized_at: DateTime.add(DateTime.utc_now(), -30, :day) + }) + + # Execute the worker + assert {:ok, %{deleted: 1, anonymized_deleted: 1}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify old records were deleted + refute Repo.get(LoginAttempt, old_active.id) + refute Repo.get(LoginAttempt, old_anon.id) + + # Verify recent records still exist + assert Repo.get(LoginAttempt, recent_active.id) + assert Repo.get(LoginAttempt, recent_anon.id) + end + + test "returns zero counts when no records need deletion" do + # Create only recent records + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -10, :day), + anonymized_at: nil + }) + + # Execute the worker + assert {:ok, %{deleted: 0, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + end + + test "works when no records exist at all" do + # Execute the worker with no records in the database + assert {:ok, %{deleted: 0, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + end + + test "respects custom retention period from config" do + # Temporarily override config (if Application.put_env works in tests) + # Otherwise, this test documents expected behavior + + # Create a record 200 days old (would be deleted if retention is 180 days) + old_attempt = + insert_login_attempt(%{ + inserted_at: DateTime.add(DateTime.utc_now(), -200, :day), + anonymized_at: nil + }) + + # With default 365 day retention, this should NOT be deleted + assert {:ok, %{deleted: 0, anonymized_deleted: 0}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify record still exists + assert Repo.get(LoginAttempt, old_attempt.id) + end + + test "handles boundary case at exactly retention cutoff" do + # Create a record at exactly 365 days old + cutoff_date = DateTime.add(DateTime.utc_now(), -365, :day) + + boundary_attempt = + insert_login_attempt(%{ + inserted_at: cutoff_date, + anonymized_at: nil + }) + + # Execute the worker + assert {:ok, %{deleted: count, anonymized_deleted: _}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # The query uses `<` not `<=`, so exactly at cutoff should NOT be deleted + assert count == 0 + assert Repo.get(LoginAttempt, boundary_attempt.id) + end + + test "handles boundary case for anonymized at exactly 90 days" do + # Create an anonymized record at exactly 90 days + anon_date = DateTime.add(DateTime.utc_now(), -90, :day) + + boundary_anon = + insert_login_attempt(%{ + inserted_at: DateTime.add(anon_date, -100, :day), + anonymized_at: anon_date + }) + + # Execute the worker + assert {:ok, %{deleted: _, anonymized_deleted: anon_count}} = + LoginHistoryCleanupWorker.perform(%Oban.Job{args: %{}}) + + # The query uses `<` not `<=`, so exactly at 90 days should NOT be deleted + assert anon_count == 0 + assert Repo.get(LoginAttempt, boundary_anon.id) + end + end + + # Helper function to create login attempts + defp insert_login_attempt(attrs) do + default_attrs = %{ + email: "test#{System.unique_integer()}@example.com", + success: true, + method: "password", + ip_address: "192.168.1.1" + } + + attrs = + default_attrs + |> Map.merge(attrs) + |> Map.update(:inserted_at, DateTime.utc_now(), fn dt -> dt end) + + # Truncate datetime values to remove microseconds (Ecto requirement) + inserted_at = attrs |> Map.get(:inserted_at) |> DateTime.truncate(:second) + + anonymized_at = + case Map.get(attrs, :anonymized_at) do + nil -> nil + dt -> DateTime.truncate(dt, :second) + end + + # Remove timestamp fields from attrs (not castable) + attrs_for_changeset = + attrs + |> Map.delete(:inserted_at) + |> Map.delete(:anonymized_at) + + changeset = + %LoginAttempt{} + |> LoginAttempt.changeset(attrs_for_changeset) + |> Ecto.Changeset.put_change(:inserted_at, inserted_at) + + # Add anonymized_at if provided + changeset = + if anonymized_at do + Ecto.Changeset.put_change(changeset, :anonymized_at, anonymized_at) + else + changeset + end + + Repo.insert!(changeset) + end +end diff --git a/test/towerops/workers/session_cleanup_worker_test.exs b/test/towerops/workers/session_cleanup_worker_test.exs new file mode 100644 index 00000000..9621c6f9 --- /dev/null +++ b/test/towerops/workers/session_cleanup_worker_test.exs @@ -0,0 +1,153 @@ +defmodule Towerops.Workers.SessionCleanupWorkerTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + alias Towerops.Accounts.BrowserSession + alias Towerops.Workers.SessionCleanupWorker + + describe "perform/1" do + test "deletes expired browser sessions" do + user = user_fixture() + {_token, user_token} = Accounts.generate_user_session_token_with_record(user) + + # Create an expired session (1 day ago) + expired_session = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + expires_at: DateTime.add(DateTime.utc_now(), -1, :day) + }) + + # Execute the worker + assert {:ok, %{sessions_deleted: 1}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify expired session was deleted + refute Repo.get(BrowserSession, expired_session.id) + end + + test "keeps non-expired browser sessions" do + user = user_fixture() + {_token, user_token} = Accounts.generate_user_session_token_with_record(user) + + # Create a valid session (expires in 1 day) + valid_session = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + expires_at: DateTime.add(DateTime.utc_now(), 1, :day) + }) + + # Execute the worker + assert {:ok, %{sessions_deleted: 0}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify valid session still exists + assert Repo.get(BrowserSession, valid_session.id) + end + + test "deletes only expired sessions among mixed sessions" do + user = user_fixture() + {_token1, user_token1} = Accounts.generate_user_session_token_with_record(user) + {_token2, user_token2} = Accounts.generate_user_session_token_with_record(user) + {_token3, user_token3} = Accounts.generate_user_session_token_with_record(user) + + # Create 2 expired sessions + expired1 = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token1.id, + expires_at: DateTime.add(DateTime.utc_now(), -2, :day) + }) + + expired2 = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token2.id, + expires_at: DateTime.add(DateTime.utc_now(), -1, :hour) + }) + + # Create 1 valid session + valid_session = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token3.id, + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + }) + + # Execute the worker + assert {:ok, %{sessions_deleted: 2}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + + # Verify expired sessions were deleted + refute Repo.get(BrowserSession, expired1.id) + refute Repo.get(BrowserSession, expired2.id) + + # Verify valid session still exists + assert Repo.get(BrowserSession, valid_session.id) + end + + test "returns zero count when no expired sessions exist" do + user = user_fixture() + {_token, user_token} = Accounts.generate_user_session_token_with_record(user) + + # Create only valid sessions + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + expires_at: DateTime.add(DateTime.utc_now(), 1, :day) + }) + + # Execute the worker + assert {:ok, %{sessions_deleted: 0}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + end + + test "works when no sessions exist at all" do + # Execute the worker with no sessions in the database + assert {:ok, %{sessions_deleted: 0}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + end + + test "handles sessions that expired exactly now" do + user = user_fixture() + {_token, user_token} = Accounts.generate_user_session_token_with_record(user) + + now = DateTime.utc_now() + + # Create a session that expires exactly now (should be considered expired) + expired_now = + insert_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + expires_at: now + }) + + # Execute the worker + assert {:ok, %{sessions_deleted: count}} = + SessionCleanupWorker.perform(%Oban.Job{args: %{}}) + + # The session should be deleted (expires_at < now, not <=) + # Actually, checking the code: where([bs], bs.expires_at < ^now) + # So expires_at == now should NOT be deleted + assert count == 0 + assert Repo.get(BrowserSession, expired_now.id) + end + end + + # Helper function to create browser sessions + defp insert_browser_session(attrs) do + default_attrs = %{ + ip_address: "192.168.1.1", + last_activity_at: DateTime.utc_now() + } + + attrs = Map.merge(default_attrs, attrs) + + %BrowserSession{} + |> BrowserSession.create_changeset(attrs) + |> Repo.insert!() + end +end