diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index a772d487..0f8e9151 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -197,26 +197,29 @@ defmodule Towerops.Alerts do def list_organization_alerts(organization_id, filters) when is_map(filters) do limit = filters["limit"] || 100 - # Use denormalized organization_id for fast query - query = - from(a in Alert, - where: a.organization_id == ^organization_id, - order_by: [desc: a.triggered_at], - limit: ^limit, - preload: [device: [:site], acknowledged_by: []] - ) - - query = - case filters["status"] do - "active" -> where(query, [a], is_nil(a.resolved_at) and is_nil(a.acknowledged_at)) - "acknowledged" -> where(query, [a], is_nil(a.resolved_at) and not is_nil(a.acknowledged_at)) - "resolved" -> where(query, [a], not is_nil(a.resolved_at)) - _ -> query - end - - Repo.all(query) + from(a in Alert, + where: a.organization_id == ^organization_id, + order_by: [desc: a.triggered_at], + limit: ^limit, + preload: [device: [:site], acknowledged_by: []] + ) + |> filter_by_status(filters["status"]) + |> filter_by_device(filters["device_id"]) + |> Repo.all() end + defp filter_by_status(query, "active"), do: where(query, [a], is_nil(a.resolved_at) and is_nil(a.acknowledged_at)) + + defp filter_by_status(query, "acknowledged"), + do: where(query, [a], is_nil(a.resolved_at) and not is_nil(a.acknowledged_at)) + + defp filter_by_status(query, "resolved"), do: where(query, [a], not is_nil(a.resolved_at)) + + defp filter_by_status(query, _), do: query + + defp filter_by_device(query, nil), do: query + defp filter_by_device(query, device_id), do: where(query, [a], a.device_id == ^device_id) + @doc """ Returns alerts for multiple organizations (for GDPR data access). Accepts a `since` option to filter alerts created after a specific date. diff --git a/test/towerops_web/controllers/api/v1/agents_controller_test.exs b/test/towerops_web/controllers/api/v1/agents_controller_test.exs new file mode 100644 index 00000000..837e8bae --- /dev/null +++ b/test/towerops_web/controllers/api/v1/agents_controller_test.exs @@ -0,0 +1,187 @@ +defmodule ToweropsWeb.Api.V1.AgentsControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.AgentsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + + 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 agents for the authenticated organization", %{ + conn: conn, + organization: organization + } do + {:ok, agent1, _raw1} = agent_token_fixture(organization.id, "Agent Alpha") + {:ok, agent2, _raw2} = agent_token_fixture(organization.id, "Agent Beta") + + conn = get(conn, ~p"/api/v1/agents") + + assert %{"data" => agents} = json_response(conn, 200) + assert length(agents) == 2 + + agent_ids = Enum.map(agents, & &1["id"]) + assert agent1.id in agent_ids + assert agent2.id in agent_ids + + agent_json = List.first(agents) + assert Map.has_key?(agent_json, "id") + assert Map.has_key?(agent_json, "name") + assert Map.has_key?(agent_json, "enabled") + assert Map.has_key?(agent_json, "last_seen_at") + assert Map.has_key?(agent_json, "last_ip") + assert Map.has_key?(agent_json, "metadata") + assert Map.has_key?(agent_json, "inserted_at") + end + + test "returns empty list when organization has no agents", %{conn: conn} do + conn = get(conn, ~p"/api/v1/agents") + + assert %{"data" => []} = json_response(conn, 200) + end + + test "does not return agents from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + {:ok, _other_agent, _raw} = agent_token_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/agents") + + assert %{"data" => agents} = json_response(conn, 200) + assert Enum.empty?(agents) + end + end + + describe "create/2" do + test "creates agent with valid name and returns token", %{ + conn: conn, + organization: organization + } do + conn = post(conn, ~p"/api/v1/agents", %{"name" => "New Agent"}) + + assert %{"data" => agent} = json_response(conn, 201) + assert agent["name"] == "New Agent" + assert is_binary(agent["id"]) + assert is_binary(agent["token"]) + assert agent["enabled"] == true + + # Verify the agent was created in the correct organization + created = Towerops.Agents.get_agent_token!(agent["id"]) + assert created.organization_id == organization.id + end + + test "returns 400 without name parameter", %{conn: conn} do + conn = post(conn, ~p"/api/v1/agents", %{}) + + assert %{"error" => "Missing 'name' parameter"} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns agent with device_count", %{conn: conn, organization: organization} do + {:ok, agent, _raw} = agent_token_fixture(organization.id, "Show Agent") + + conn = get(conn, ~p"/api/v1/agents/#{agent.id}") + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == agent.id + assert data["name"] == "Show Agent" + assert Map.has_key?(data, "device_count") + assert data["device_count"] == 0 + end + + test "returns 404 for non-existent agent", %{conn: conn} do + conn = get(conn, ~p"/api/v1/agents/#{Ecto.UUID.generate()}") + + assert %{"error" => "Agent not found"} = json_response(conn, 404) + end + + test "returns 404 for agent in another organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + {:ok, other_agent, _raw} = agent_token_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/agents/#{other_agent.id}") + + assert %{"error" => "Agent not found"} = json_response(conn, 404) + end + end + + describe "delete/2" do + test "removes agent and returns 204", %{conn: conn, organization: organization} do + {:ok, agent, _raw} = agent_token_fixture(organization.id) + + conn = delete(conn, ~p"/api/v1/agents/#{agent.id}") + + assert response(conn, 204) + + # Verify the agent was deleted + assert_raise Ecto.NoResultsError, fn -> + Towerops.Agents.get_agent_token!(agent.id) + end + end + + test "returns 404 for non-existent agent", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/agents/#{Ecto.UUID.generate()}") + + assert %{"error" => "Agent not found"} = json_response(conn, 404) + end + + test "returns 404 for agent in another organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + {:ok, other_agent, _raw} = agent_token_fixture(other_org.id) + + conn = delete(conn, ~p"/api/v1/agents/#{other_agent.id}") + + assert %{"error" => "Agent not found"} = json_response(conn, 404) + 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/agents") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/agents") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/alerts_controller_test.exs b/test/towerops_web/controllers/api/v1/alerts_controller_test.exs new file mode 100644 index 00000000..28ff049c --- /dev/null +++ b/test/towerops_web/controllers/api/v1/alerts_controller_test.exs @@ -0,0 +1,254 @@ +defmodule ToweropsWeb.Api.V1.AlertsControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Alerts + alias Towerops.ApiTokens + + 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 + + defp create_alert(device, attrs \\ %{}) do + default_attrs = %{ + device_id: device.id, + alert_type: "ping_down", + message: "Device unreachable", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, alert} = Alerts.create_alert(Map.merge(default_attrs, attrs)) + alert + end + + describe "index/2" do + test "lists alerts for the authenticated organization", %{ + conn: conn, + organization: organization + } do + device = device_fixture(%{organization_id: organization.id}) + alert1 = create_alert(device) + alert2 = create_alert(device, %{alert_type: "device_down", message: "SNMP timeout"}) + + conn = get(conn, ~p"/api/v1/alerts") + + assert %{"data" => alerts} = json_response(conn, 200) + assert length(alerts) == 2 + + alert_ids = Enum.map(alerts, & &1["id"]) + assert alert1.id in alert_ids + assert alert2.id in alert_ids + + alert_json = List.first(alerts) + assert Map.has_key?(alert_json, "id") + assert Map.has_key?(alert_json, "alert_type") + assert Map.has_key?(alert_json, "message") + assert Map.has_key?(alert_json, "triggered_at") + assert Map.has_key?(alert_json, "acknowledged_at") + assert Map.has_key?(alert_json, "resolved_at") + assert Map.has_key?(alert_json, "device_id") + assert Map.has_key?(alert_json, "device_name") + assert Map.has_key?(alert_json, "acknowledged_by_email") + assert Map.has_key?(alert_json, "gaiia_impact") + assert Map.has_key?(alert_json, "inserted_at") + end + + test "returns empty list when organization has no alerts", %{conn: conn} do + conn = get(conn, ~p"/api/v1/alerts") + + assert %{"data" => []} = json_response(conn, 200) + end + + test "filters by status param for open alerts", %{ + conn: conn, + organization: organization + } do + device = device_fixture(%{organization_id: organization.id}) + _open_alert = create_alert(device) + + resolved_alert = create_alert(device, %{alert_type: "device_down", message: "Resolved one"}) + Alerts.resolve_alert(resolved_alert) + + conn = get(conn, ~p"/api/v1/alerts?status=active") + + assert %{"data" => alerts} = json_response(conn, 200) + assert length(alerts) == 1 + + [returned] = alerts + assert is_nil(returned["resolved_at"]) + end + + test "filters by device_id param", %{ + conn: conn, + organization: organization + } do + device1 = device_fixture(%{organization_id: organization.id}) + device2 = device_fixture(%{organization_id: organization.id}) + + _alert1 = create_alert(device1) + _alert2 = create_alert(device2) + + conn = get(conn, ~p"/api/v1/alerts?device_id=#{device1.id}") + + assert %{"data" => alerts} = json_response(conn, 200) + assert length(alerts) == 1 + assert List.first(alerts)["device_id"] == device1.id + end + + test "does not return alerts from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_device = device_fixture(%{organization_id: other_org.id}) + _other_alert = create_alert(other_device) + + conn = get(conn, ~p"/api/v1/alerts") + + assert %{"data" => []} = json_response(conn, 200) + end + end + + describe "show/2" do + test "returns alert details when it belongs to the organization", %{ + conn: conn, + organization: organization + } do + device = device_fixture(%{organization_id: organization.id}) + alert = create_alert(device) + + conn = get(conn, ~p"/api/v1/alerts/#{alert.id}") + + assert %{"data" => alert_json} = json_response(conn, 200) + assert alert_json["id"] == alert.id + assert alert_json["alert_type"] == "ping_down" + assert alert_json["message"] == "Device unreachable" + assert alert_json["device_id"] == device.id + assert alert_json["device_name"] == device.name + end + + test "returns 404 when alert does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/alerts/#{Ecto.UUID.generate()}") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + end + + test "returns 404 when alert belongs to a different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_device = device_fixture(%{organization_id: other_org.id}) + other_alert = create_alert(other_device) + + conn = get(conn, ~p"/api/v1/alerts/#{other_alert.id}") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + end + end + + describe "acknowledge/2" do + test "sets acknowledged_at on the alert", %{ + conn: conn, + organization: organization + } do + device = device_fixture(%{organization_id: organization.id}) + alert = create_alert(device) + + conn = post(conn, ~p"/api/v1/alerts/#{alert.id}/acknowledge") + + assert %{"data" => alert_json} = json_response(conn, 200) + assert alert_json["id"] == alert.id + assert alert_json["acknowledged_at"] + end + + test "returns 404 when alert belongs to a different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_device = device_fixture(%{organization_id: other_org.id}) + other_alert = create_alert(other_device) + + conn = post(conn, ~p"/api/v1/alerts/#{other_alert.id}/acknowledge") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + end + + test "returns 404 when alert does not exist", %{conn: conn} do + conn = post(conn, ~p"/api/v1/alerts/#{Ecto.UUID.generate()}/acknowledge") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + end + end + + describe "resolve/2" do + test "sets resolved_at on the alert", %{ + conn: conn, + organization: organization + } do + device = device_fixture(%{organization_id: organization.id}) + alert = create_alert(device) + + conn = post(conn, ~p"/api/v1/alerts/#{alert.id}/resolve") + + assert %{"data" => alert_json} = json_response(conn, 200) + assert alert_json["id"] == alert.id + assert alert_json["resolved_at"] + end + + test "returns 404 when alert belongs to a different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_device = device_fixture(%{organization_id: other_org.id}) + other_alert = create_alert(other_device) + + conn = post(conn, ~p"/api/v1/alerts/#{other_alert.id}/resolve") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + end + + test "returns 404 when alert does not exist", %{conn: conn} do + conn = post(conn, ~p"/api/v1/alerts/#{Ecto.UUID.generate()}/resolve") + + assert %{"error" => "Alert not found"} = json_response(conn, 404) + 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/alerts") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/alerts") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/integrations_controller_test.exs b/test/towerops_web/controllers/api/v1/integrations_controller_test.exs new file mode 100644 index 00000000..8c498bb3 --- /dev/null +++ b/test/towerops_web/controllers/api/v1/integrations_controller_test.exs @@ -0,0 +1,262 @@ +defmodule ToweropsWeb.Api.V1.IntegrationsControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + + 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 integrations for authenticated organization", %{ + conn: conn, + organization: organization + } do + integration1 = integration_fixture(organization.id, %{provider: "preseem"}) + integration2 = integration_fixture(organization.id, %{provider: "gaiia"}) + + conn = get(conn, ~p"/api/v1/integrations") + + assert %{"data" => integrations} = json_response(conn, 200) + assert length(integrations) == 2 + + ids = Enum.map(integrations, & &1["id"]) + assert integration1.id in ids + assert integration2.id in ids + + integration_json = List.first(integrations) + assert Map.has_key?(integration_json, "id") + assert Map.has_key?(integration_json, "provider") + assert Map.has_key?(integration_json, "enabled") + assert Map.has_key?(integration_json, "sync_interval_minutes") + assert Map.has_key?(integration_json, "last_synced_at") + assert Map.has_key?(integration_json, "last_sync_status") + assert Map.has_key?(integration_json, "last_sync_message") + assert Map.has_key?(integration_json, "inserted_at") + assert Map.has_key?(integration_json, "updated_at") + end + + test "returns empty list when organization has no integrations", %{conn: conn} do + conn = get(conn, ~p"/api/v1/integrations") + + assert %{"data" => []} = json_response(conn, 200) + end + + test "does not return integrations from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + _other_integration = integration_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/integrations") + + assert %{"data" => integrations} = json_response(conn, 200) + assert Enum.empty?(integrations) + end + end + + describe "create/2" do + test "creates integration with valid params", %{conn: conn} do + params = %{ + "integration" => %{ + "provider" => "preseem", + "enabled" => true, + "credentials" => %{"api_key" => "test-key-123"} + } + } + + conn = post(conn, ~p"/api/v1/integrations", params) + + assert %{"data" => integration} = json_response(conn, 201) + assert integration["provider"] == "preseem" + assert integration["enabled"] == true + assert is_binary(integration["id"]) + end + + test "returns 400 when integration parameter is missing", %{conn: conn} do + conn = post(conn, ~p"/api/v1/integrations", %{}) + + assert %{"error" => "Missing 'integration' parameter"} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns integration when it belongs to organization", %{ + conn: conn, + organization: organization + } do + integration = integration_fixture(organization.id, %{provider: "preseem"}) + + conn = get(conn, ~p"/api/v1/integrations/#{integration.id}") + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == integration.id + assert data["provider"] == "preseem" + assert data["enabled"] == true + end + + test "returns 404 when integration belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_integration = integration_fixture(other_org.id) + + conn = get(conn, ~p"/api/v1/integrations/#{other_integration.id}") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + + test "returns 404 when integration does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/integrations/#{Ecto.UUID.generate()}") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + end + + describe "update/2" do + test "updates integration with valid attributes", %{ + conn: conn, + organization: organization + } do + integration = integration_fixture(organization.id, %{provider: "preseem", enabled: true}) + + update_params = %{ + "integration" => %{ + "enabled" => false + } + } + + conn = patch(conn, ~p"/api/v1/integrations/#{integration.id}", update_params) + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == integration.id + assert data["enabled"] == false + end + + test "returns 400 when integration parameter is missing", %{ + conn: conn, + organization: organization + } do + integration = integration_fixture(organization.id) + + conn = patch(conn, ~p"/api/v1/integrations/#{integration.id}", %{}) + + assert %{"error" => "Missing 'integration' parameter"} = json_response(conn, 400) + end + + test "returns 404 when integration belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_integration = integration_fixture(other_org.id) + + update_params = %{ + "integration" => %{ + "enabled" => false + } + } + + conn = patch(conn, ~p"/api/v1/integrations/#{other_integration.id}", update_params) + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + end + + describe "delete/2" do + test "deletes integration successfully", %{conn: conn, organization: organization} do + integration = integration_fixture(organization.id) + + conn = delete(conn, ~p"/api/v1/integrations/#{integration.id}") + + assert response(conn, 204) + end + + test "returns 404 when integration belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_integration = integration_fixture(other_org.id) + + conn = delete(conn, ~p"/api/v1/integrations/#{other_integration.id}") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + + test "returns 404 when integration does not exist", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/integrations/#{Ecto.UUID.generate()}") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + end + + describe "test_connection/2" do + test "returns ok status for valid integration", %{ + conn: conn, + organization: organization + } do + integration = integration_fixture(organization.id, %{provider: "preseem"}) + + conn = post(conn, ~p"/api/v1/integrations/#{integration.id}/test") + + assert %{"data" => %{"status" => "ok", "provider" => "preseem"}} = + json_response(conn, 200) + end + + test "returns 404 when integration belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_integration = integration_fixture(other_org.id) + + conn = post(conn, ~p"/api/v1/integrations/#{other_integration.id}/test") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + end + + test "returns 404 when integration does not exist", %{conn: conn} do + conn = post(conn, ~p"/api/v1/integrations/#{Ecto.UUID.generate()}/test") + + assert %{"error" => "Integration not found"} = json_response(conn, 404) + 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/integrations") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/integrations") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/invitations_controller_test.exs b/test/towerops_web/controllers/api/v1/invitations_controller_test.exs new file mode 100644 index 00000000..0370d853 --- /dev/null +++ b/test/towerops_web/controllers/api/v1/invitations_controller_test.exs @@ -0,0 +1,144 @@ +defmodule ToweropsWeb.Api.V1.InvitationsControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.Organizations + + 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 + + defp create_test_invitation(organization, user, email \\ "invited@example.com") do + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: email, + role: :technician, + invited_by_id: user.id, + token: Ecto.UUID.generate(), + expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second) + }) + + invitation + end + + describe "index/2" do + test "lists pending invitations", %{ + conn: conn, + organization: organization, + user: user + } do + invitation = create_test_invitation(organization, user) + + conn = get(conn, ~p"/api/v1/invitations") + + assert %{"data" => invitations} = json_response(conn, 200) + assert length(invitations) == 1 + + inv = List.first(invitations) + assert inv["id"] == invitation.id + assert inv["email"] == "invited@example.com" + assert inv["role"] == "technician" + assert Map.has_key?(inv, "expires_at") + assert Map.has_key?(inv, "invited_by_email") + assert Map.has_key?(inv, "inserted_at") + end + + test "returns empty list when no invitations exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/invitations") + + assert %{"data" => []} = json_response(conn, 200) + end + end + + describe "create/2" do + test "creates invitation", %{conn: conn} do + params = %{ + "email" => "newuser@example.com", + "role" => "technician" + } + + conn = post(conn, ~p"/api/v1/invitations", params) + + assert %{"data" => data} = json_response(conn, 201) + assert data["email"] == "newuser@example.com" + assert data["role"] == "technician" + assert Map.has_key?(data, "id") + assert Map.has_key?(data, "expires_at") + assert Map.has_key?(data, "inserted_at") + end + + test "returns 400 without required params", %{conn: conn} do + conn = post(conn, ~p"/api/v1/invitations", %{}) + + assert %{"error" => "Missing 'email' and 'role' parameters"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "removes invitation", %{conn: conn, organization: organization, user: user} do + invitation = create_test_invitation(organization, user) + + conn = delete(conn, ~p"/api/v1/invitations/#{invitation.id}") + + assert response(conn, 204) + end + + test "returns 404 for invitation in different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + invitation = create_test_invitation(other_org, other_user, "other@example.com") + + conn = delete(conn, ~p"/api/v1/invitations/#{invitation.id}") + + assert %{"error" => "Invitation not found"} = json_response(conn, 404) + end + + test "returns 404 for non-existent invitation", %{conn: conn} do + fake_id = Ecto.UUID.generate() + + conn = delete(conn, ~p"/api/v1/invitations/#{fake_id}") + + assert %{"error" => "Invitation not found"} = json_response(conn, 404) + 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/invitations") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/invitations") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/members_controller_test.exs b/test/towerops_web/controllers/api/v1/members_controller_test.exs new file mode 100644 index 00000000..ddfc437a --- /dev/null +++ b/test/towerops_web/controllers/api/v1/members_controller_test.exs @@ -0,0 +1,155 @@ +defmodule ToweropsWeb.Api.V1.MembersControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.Organizations + + 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 + + defp add_member(organization, role \\ :technician) do + other_user = user_fixture() + + {:ok, _membership} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: other_user.id, + role: role + }) + + other_user + end + + describe "index/2" do + test "lists members including owner", %{conn: conn, user: user} do + conn = get(conn, ~p"/api/v1/members") + + assert %{"data" => members} = json_response(conn, 200) + assert [_ | _] = members + + owner = Enum.find(members, &(&1["id"] == user.id)) + assert owner["email"] == user.email + assert owner["role"] == "owner" + assert Map.has_key?(owner, "inserted_at") + end + + test "lists multiple members", %{conn: conn, organization: organization} do + other_user = add_member(organization, :technician) + + conn = get(conn, ~p"/api/v1/members") + + assert %{"data" => members} = json_response(conn, 200) + assert length(members) == 2 + + member_ids = Enum.map(members, & &1["id"]) + assert other_user.id in member_ids + end + end + + describe "update/2" do + test "changes member role", %{conn: conn, organization: organization} do + other_user = add_member(organization, :technician) + + conn = + patch(conn, ~p"/api/v1/members/#{other_user.id}", %{ + "role" => "admin" + }) + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == other_user.id + assert data["role"] == "admin" + end + + test "returns 403 when changing owner role", %{conn: conn, user: user} do + conn = + patch(conn, ~p"/api/v1/members/#{user.id}", %{ + "role" => "technician" + }) + + assert %{"error" => "Cannot change owner role"} = json_response(conn, 403) + end + + test "returns 404 for non-member", %{conn: conn} do + fake_id = Ecto.UUID.generate() + + conn = + patch(conn, ~p"/api/v1/members/#{fake_id}", %{ + "role" => "admin" + }) + + assert %{"error" => "Member not found"} = json_response(conn, 404) + end + + test "returns 400 without role param", %{conn: conn, organization: organization} do + other_user = add_member(organization) + + conn = patch(conn, ~p"/api/v1/members/#{other_user.id}", %{}) + + assert %{"error" => "Missing 'role' parameter"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "removes member", %{conn: conn, organization: organization} do + other_user = add_member(organization) + + conn = delete(conn, ~p"/api/v1/members/#{other_user.id}") + + assert response(conn, 204) + end + + test "returns 403 when removing owner", %{conn: conn, user: user} do + conn = delete(conn, ~p"/api/v1/members/#{user.id}") + + assert %{"error" => "Cannot remove owner"} = json_response(conn, 403) + end + + test "returns 404 for non-member", %{conn: conn} do + fake_id = Ecto.UUID.generate() + + conn = delete(conn, ~p"/api/v1/members/#{fake_id}") + + assert %{"error" => "Member not found"} = json_response(conn, 404) + 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/members") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/members") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/controllers/api/v1/organization_controller_test.exs b/test/towerops_web/controllers/api/v1/organization_controller_test.exs new file mode 100644 index 00000000..5d40a50c --- /dev/null +++ b/test/towerops_web/controllers/api/v1/organization_controller_test.exs @@ -0,0 +1,134 @@ +defmodule ToweropsWeb.Api.V1.OrganizationControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + + 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 "show/2" do + test "returns organization data", %{conn: conn, organization: organization} do + conn = get(conn, ~p"/api/v1/organization") + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == organization.id + assert data["name"] == organization.name + assert data["slug"] == organization.slug + assert Map.has_key?(data, "subscription_plan") + assert Map.has_key?(data, "use_sites") + assert Map.has_key?(data, "snmp_version") + assert Map.has_key?(data, "snmp_community") + assert Map.has_key?(data, "snmp_port") + assert Map.has_key?(data, "inserted_at") + assert Map.has_key?(data, "updated_at") + end + end + + describe "update/2" do + test "updates organization name", %{conn: conn, organization: organization} do + update_params = %{ + "organization" => %{ + "name" => "Updated Org Name" + } + } + + conn = patch(conn, ~p"/api/v1/organization", update_params) + + assert %{"data" => data} = json_response(conn, 200) + assert data["id"] == organization.id + assert data["name"] == "Updated Org Name" + end + + test "returns 403 for non-owner", %{organization: organization} do + other_user = user_fixture() + + {:ok, _membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: other_user.id, + role: :technician + }) + + {:ok, {_token, other_raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: other_user.id, + name: "Other Token" + }) + + conn = + build_conn() + |> put_req_header("authorization", "Bearer #{other_raw_token}") + |> put_req_header("accept", "application/json") + + update_params = %{ + "organization" => %{ + "name" => "Unauthorized Update" + } + } + + conn = patch(conn, ~p"/api/v1/organization", update_params) + + assert %{"error" => _message} = json_response(conn, 403) + end + + test "returns 400 without organization param", %{conn: conn} do + conn = patch(conn, ~p"/api/v1/organization", %{}) + + assert %{"error" => "Missing 'organization' parameter"} = json_response(conn, 400) + end + + test "returns 422 with invalid params", %{conn: conn} do + update_params = %{ + "organization" => %{ + "name" => "" + } + } + + conn = patch(conn, ~p"/api/v1/organization", update_params) + + assert %{"errors" => errors} = json_response(conn, 422) + assert Map.has_key?(errors, "name") + 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/organization") + + assert %{"error" => _message} = json_response(conn, 401) + end + + test "returns 401 with invalid token" do + conn = + build_conn() + |> put_req_header("authorization", "Bearer towerops_invalid_token") + |> put_req_header("accept", "application/json") + |> get(~p"/api/v1/organization") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end