diff --git a/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex b/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex new file mode 100644 index 00000000..17f5cf2b --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex @@ -0,0 +1,163 @@ +defmodule ToweropsWeb.Api.V1.MaintenanceWindowsController do + @moduledoc """ + API controller for managing maintenance windows. + + All endpoints require API token authentication and operations are scoped + to the organization associated with the token. + """ + use ToweropsWeb, :controller + + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + + alias Towerops.Maintenance + alias Towerops.Maintenance.MaintenanceWindow + alias ToweropsWeb.ScopedResource + + @doc "GET /api/v1/maintenance_windows" + def index(conn, params) do + organization_id = conn.assigns.current_organization_id + filter = Map.get(params, "filter") + + opts = + case filter do + "active" -> [filter: :active] + "upcoming" -> [filter: :upcoming] + "past" -> [filter: :past] + _ -> [] + end + + windows = + organization_id + |> Maintenance.list_windows(opts) + |> Enum.map(&format_window/1) + + json(conn, %{maintenance_windows: windows}) + end + + @doc "POST /api/v1/maintenance_windows" + def create(conn, %{"maintenance_window" => window_params}) do + organization_id = conn.assigns.current_organization_id + user_id = conn.assigns.current_user.id + + attrs = + window_params + |> Map.put("organization_id", organization_id) + |> Map.put("created_by_id", user_id) + + case Maintenance.create_window(attrs) do + {:ok, window} -> + conn + |> put_status(:created) + |> json(format_window(window)) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + end + + def create(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing 'maintenance_window' parameter"}) + end + + @doc "GET /api/v1/maintenance_windows/:id" + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do + {:ok, window} -> + json(conn, format_window(window)) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Access denied to this maintenance window"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Maintenance window not found"}) + end + end + + @doc "PATCH /api/v1/maintenance_windows/:id" + def update(conn, %{"id" => id, "maintenance_window" => window_params}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do + {:ok, window} -> + case Maintenance.update_window(window, window_params) do + {:ok, updated} -> + json(conn, format_window(updated)) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Access denied to this maintenance window"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Maintenance window not found"}) + end + end + + def update(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing 'maintenance_window' parameter"}) + end + + @doc "DELETE /api/v1/maintenance_windows/:id" + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do + {:ok, window} -> + case Maintenance.delete_window(window) do + {:ok, _} -> + json(conn, %{success: true}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Access denied to this maintenance window"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Maintenance window not found"}) + end + end + + defp format_window(window) do + %{ + id: window.id, + name: window.name, + reason: window.reason, + starts_at: window.starts_at, + ends_at: window.ends_at, + recurring: window.recurring, + recurrence_rule: window.recurrence_rule, + suppress_alerts: window.suppress_alerts, + site_id: window.site_id, + device_id: window.device_id, + inserted_at: window.inserted_at + } + end +end diff --git a/lib/towerops_web/graphql/resolvers/maintenance_window.ex b/lib/towerops_web/graphql/resolvers/maintenance_window.ex new file mode 100644 index 00000000..fd4ab575 --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/maintenance_window.ex @@ -0,0 +1,79 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow do + @moduledoc "GraphQL resolvers for maintenance window queries and mutations." + + alias Towerops.Maintenance + alias Towerops.Maintenance.MaintenanceWindow + alias ToweropsWeb.GraphQL.Resolvers.Helpers + alias ToweropsWeb.ScopedResource + + def list(_parent, args, %{context: %{organization_id: org_id}}) do + opts = + case Map.get(args, :filter) do + "active" -> [filter: :active] + "upcoming" -> [filter: :upcoming] + "past" -> [filter: :past] + _ -> [] + end + + {:ok, Maintenance.list_windows(org_id, opts)} + end + + def list(_parent, _args, _resolution), do: Helpers.authentication_error() + + def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case ScopedResource.fetch(MaintenanceWindow, id, org_id) do + {:ok, window} -> {:ok, window} + {:error, _} -> {:error, "Maintenance window not found"} + end + end + + def get(_parent, _args, _resolution), do: Helpers.authentication_error() + + def create(_parent, %{input: input}, %{context: %{organization_id: org_id, user: user}}) do + user_id = user.id + + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("organization_id", org_id) + |> Map.put("created_by_id", user_id) + + case Maintenance.create_window(attrs) do + {:ok, window} -> {:ok, window} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + + def create(_parent, _args, _resolution), do: Helpers.authentication_error() + + def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + with {:ok, window} <- fetch_org_window(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case Maintenance.update_window(window, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end + end + end + + def update(_parent, _args, _resolution), do: Helpers.authentication_error() + + def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + with {:ok, window} <- fetch_org_window(id, org_id) do + case Maintenance.delete_window(window) do + {:ok, _} -> {:ok, %{success: true, message: "Maintenance window deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete maintenance window"}} + end + end + end + + def delete(_parent, _args, _resolution), do: Helpers.authentication_error() + + defp fetch_org_window(id, org_id) do + case ScopedResource.fetch(MaintenanceWindow, id, org_id) do + {:ok, window} -> {:ok, window} + {:error, _} -> {:error, "Maintenance window not found"} + end + end +end diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 68a9e7d1..f53ddd9a 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -18,6 +18,7 @@ defmodule ToweropsWeb.GraphQL.Schema do import_types(ToweropsWeb.GraphQL.Types.Activity) import_types(ToweropsWeb.GraphQL.Types.Schedule) import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy) + import_types(ToweropsWeb.GraphQL.Types.MaintenanceWindow) query do # Devices @@ -126,6 +127,17 @@ defmodule ToweropsWeb.GraphQL.Schema do arg(:id, non_null(:id)) resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3) end + + # Maintenance Windows + field :maintenance_windows, list_of(:maintenance_window) do + arg(:filter, :string) + resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.list/3) + end + + field :maintenance_window, :maintenance_window do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.get/3) + end end mutation do @@ -342,5 +354,22 @@ defmodule ToweropsWeb.GraphQL.Schema do arg(:id, non_null(:id)) resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_target/3) end + + # Maintenance Window CRUD + field :create_maintenance_window, :maintenance_window do + arg(:input, non_null(:maintenance_window_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.create/3) + end + + field :update_maintenance_window, :maintenance_window do + arg(:id, non_null(:id)) + arg(:input, non_null(:maintenance_window_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.update/3) + end + + field :delete_maintenance_window, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.delete/3) + end end end diff --git a/lib/towerops_web/graphql/types/maintenance_window.ex b/lib/towerops_web/graphql/types/maintenance_window.ex new file mode 100644 index 00000000..5aeb6d3f --- /dev/null +++ b/lib/towerops_web/graphql/types/maintenance_window.ex @@ -0,0 +1,32 @@ +defmodule ToweropsWeb.GraphQL.Types.MaintenanceWindow do + @moduledoc "GraphQL types for maintenance windows." + use Absinthe.Schema.Notation + + object :maintenance_window do + field :id, :id + field :name, :string + field :reason, :string + field :starts_at, :string + field :ends_at, :string + field :recurring, :boolean + field :recurrence_rule, :string + field :suppress_alerts, :boolean + field :site_id, :id + field :device_id, :id + field :organization_id, :id + field :inserted_at, :string + field :updated_at, :string + end + + input_object :maintenance_window_input do + field :name, :string + field :reason, :string + field :starts_at, :string + field :ends_at, :string + field :recurring, :boolean + field :recurrence_rule, :string + field :suppress_alerts, :boolean + field :site_id, :id + field :device_id, :id + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 87d8907e..e45a946b 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -188,6 +188,9 @@ defmodule ToweropsWeb.Router do delete "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :delete_rule post "/escalation_policies/:id/rules/:rule_id/targets", EscalationPoliciesController, :create_target delete "/escalation_policies/:id/rules/:rule_id/targets/:target_id", EscalationPoliciesController, :delete_target + + # Maintenance windows + resources "/maintenance_windows", MaintenanceWindowsController, except: [:new, :edit] end # Webhook routes (shared secret authentication) diff --git a/test/support/fixtures/maintenance_fixtures.ex b/test/support/fixtures/maintenance_fixtures.ex new file mode 100644 index 00000000..2c235140 --- /dev/null +++ b/test/support/fixtures/maintenance_fixtures.ex @@ -0,0 +1,22 @@ +defmodule Towerops.MaintenanceFixtures do + @moduledoc """ + Test helpers for creating maintenance window entities. + """ + + alias Towerops.Maintenance.MaintenanceWindow + alias Towerops.Repo + + def maintenance_window_fixture(attrs) do + attrs = + Enum.into(attrs, %{ + name: "Maintenance #{System.unique_integer([:positive])}", + starts_at: DateTime.add(DateTime.utc_now(), -3600, :second), + ends_at: DateTime.add(DateTime.utc_now(), 3600, :second), + suppress_alerts: true + }) + + %MaintenanceWindow{} + |> MaintenanceWindow.changeset(attrs) + |> Repo.insert!() + end +end diff --git a/test/towerops_web/controllers/api/v1/maintenance_windows_controller_test.exs b/test/towerops_web/controllers/api/v1/maintenance_windows_controller_test.exs new file mode 100644 index 00000000..5669666f --- /dev/null +++ b/test/towerops_web/controllers/api/v1/maintenance_windows_controller_test.exs @@ -0,0 +1,271 @@ +defmodule ToweropsWeb.Api.V1.MaintenanceWindowsControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.MaintenanceFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.Maintenance + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + conn = + build_conn() + |> put_req_header("authorization", "Bearer #{raw_token}") + |> put_req_header("accept", "application/json") + + %{ + conn: conn, + user: user, + organization: organization + } + end + + describe "index/2" do + test "lists all maintenance windows for organization", %{ + conn: conn, + user: user, + organization: organization + } do + _window = + maintenance_window_fixture(%{ + organization_id: organization.id, + created_by_id: user.id + }) + + conn = get(conn, ~p"/api/v1/maintenance_windows") + + assert %{"maintenance_windows" => windows} = json_response(conn, 200) + assert length(windows) == 1 + + window_json = List.first(windows) + assert Map.has_key?(window_json, "id") + assert Map.has_key?(window_json, "name") + assert Map.has_key?(window_json, "starts_at") + assert Map.has_key?(window_json, "ends_at") + end + + test "returns empty list when organization has no windows", %{conn: conn} do + conn = get(conn, ~p"/api/v1/maintenance_windows") + + assert %{"maintenance_windows" => []} = json_response(conn, 200) + end + + test "filters by status", %{conn: conn, user: user, organization: organization} do + _active_window = + maintenance_window_fixture(%{ + organization_id: organization.id, + created_by_id: user.id, + starts_at: DateTime.add(DateTime.utc_now(), -3600, :second), + ends_at: DateTime.add(DateTime.utc_now(), 3600, :second) + }) + + conn = get(conn, ~p"/api/v1/maintenance_windows?filter=active") + + assert %{"maintenance_windows" => windows} = json_response(conn, 200) + assert length(windows) == 1 + end + + test "does not return windows from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + _other_window = + maintenance_window_fixture(%{ + organization_id: other_org.id, + created_by_id: other_user.id + }) + + conn = get(conn, ~p"/api/v1/maintenance_windows") + + assert %{"maintenance_windows" => []} = json_response(conn, 200) + end + end + + describe "create/2" do + test "creates window with valid attributes", %{ + conn: conn, + user: user, + organization: organization + } do + starts_at = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601() + ends_at = DateTime.utc_now() |> DateTime.add(7200, :second) |> DateTime.to_iso8601() + + params = %{ + "maintenance_window" => %{ + "name" => "Scheduled Upgrade", + "reason" => "Router firmware upgrade", + "starts_at" => starts_at, + "ends_at" => ends_at, + "suppress_alerts" => true + } + } + + conn = post(conn, ~p"/api/v1/maintenance_windows", params) + + assert %{ + "id" => id, + "name" => "Scheduled Upgrade", + "reason" => "Router firmware upgrade" + } = json_response(conn, 201) + + assert is_binary(id) + window = Maintenance.get_window!(id) + assert window.organization_id == organization.id + assert window.created_by_id == user.id + end + + test "returns 422 with invalid params", %{conn: conn} do + params = %{"maintenance_window" => %{"name" => ""}} + + conn = post(conn, ~p"/api/v1/maintenance_windows", params) + + assert %{"errors" => _} = json_response(conn, 422) + end + + test "returns 400 when maintenance_window parameter is missing", %{conn: conn} do + conn = post(conn, ~p"/api/v1/maintenance_windows", %{}) + + assert %{"error" => "Missing 'maintenance_window' parameter"} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns window when it belongs to organization", %{ + conn: conn, + user: user, + organization: organization + } do + window = + maintenance_window_fixture(%{ + name: "Test Window", + organization_id: organization.id, + created_by_id: user.id + }) + + conn = get(conn, ~p"/api/v1/maintenance_windows/#{window.id}") + + assert %{"id" => _, "name" => "Test Window"} = json_response(conn, 200) + end + + test "returns 404 when window does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}") + + assert %{"error" => "Maintenance window not found"} = json_response(conn, 404) + end + + test "returns 403 when window belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + other_window = + maintenance_window_fixture(%{ + organization_id: other_org.id, + created_by_id: other_user.id + }) + + conn = get(conn, ~p"/api/v1/maintenance_windows/#{other_window.id}") + + assert %{"error" => "Access denied to this maintenance window"} = json_response(conn, 403) + end + end + + describe "update/2" do + test "updates window with valid attributes", %{ + conn: conn, + user: user, + organization: organization + } do + window = + maintenance_window_fixture(%{ + name: "Original", + organization_id: organization.id, + created_by_id: user.id + }) + + params = %{"maintenance_window" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/maintenance_windows/#{window.id}", params) + + assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200) + end + + test "returns 404 when window does not exist", %{conn: conn} do + params = %{"maintenance_window" => %{"name" => "Updated"}} + conn = patch(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}", params) + + assert %{"error" => "Maintenance window not found"} = json_response(conn, 404) + end + + test "returns 400 when maintenance_window parameter is missing", %{ + conn: conn, + user: user, + organization: organization + } do + window = + maintenance_window_fixture(%{ + organization_id: organization.id, + created_by_id: user.id + }) + + conn = patch(conn, ~p"/api/v1/maintenance_windows/#{window.id}", %{}) + + assert %{"error" => "Missing 'maintenance_window' parameter"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "deletes window successfully", %{conn: conn, user: user, organization: organization} do + window = + maintenance_window_fixture(%{ + organization_id: organization.id, + created_by_id: user.id + }) + + conn = delete(conn, ~p"/api/v1/maintenance_windows/#{window.id}") + + assert %{"success" => true} = json_response(conn, 200) + end + + test "returns 404 when window does not exist", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}") + + assert %{"error" => "Maintenance window not found"} = json_response(conn, 404) + end + + test "returns 403 when window belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + other_window = + maintenance_window_fixture(%{ + organization_id: other_org.id, + created_by_id: other_user.id + }) + + conn = delete(conn, ~p"/api/v1/maintenance_windows/#{other_window.id}") + + assert %{"error" => "Access denied to this maintenance window"} = json_response(conn, 403) + end + end + + describe "authentication" do + test "returns 401 without authorization header" do + conn = + build_conn() + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/maintenance_windows") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/live/agent_live/edit_test.exs b/test/towerops_web/live/agent_live/edit_test.exs index 6e6c4b13..f5bf5e03 100644 --- a/test/towerops_web/live/agent_live/edit_test.exs +++ b/test/towerops_web/live/agent_live/edit_test.exs @@ -103,7 +103,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do flash = assert_redirect(view, ~p"/agents/#{agent.id}") - assert flash["info"] =~ "Device updated successfully" + assert flash["info"] =~ "Agent updated successfully" end test "shows error when saving with blank name", %{conn: conn, organization: org} do