diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 898d2f85..83fd4ac0 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,21 @@ +2026-03-16 +feat: add service checks REST API, GraphQL API, and ping executor + - New REST API: full CRUD at /api/v1/checks for HTTP, TCP, DNS, and ping checks + - New GraphQL API: checks/check queries and createCheck/updateCheck/deleteCheck mutations + - New ping executor: replaces stub with real ICMP ping via system ping command + - Ping executor parses both macOS and Linux output for packet loss and RTT + - Check config validation added for ping type (requires "host") + - API docs updated with Checks resource section + Files: lib/towerops/monitoring/executors/ping_executor.ex (new), + lib/towerops/workers/check_executor_worker.ex, + lib/towerops/monitoring/check.ex, + lib/towerops_web/controllers/api/v1/checks_controller.ex (new), + lib/towerops_web/router.ex, + lib/towerops_web/graphql/types/check.ex (new), + lib/towerops_web/graphql/resolvers/check.ex (new), + lib/towerops_web/graphql/schema.ex, + lib/towerops_web/controllers/api_docs_html/index.html.heex + 2026-03-14 fix: network map label size, site layout, and missing links - Increased node label font from 11px to 13px and compound node label to 14px diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 14510717..c946c224 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -119,6 +119,7 @@ defmodule Towerops.Monitoring.Check do "http" -> validate_http_config(changeset, config) "tcp" -> validate_tcp_config(changeset, config) "dns" -> validate_dns_config(changeset, config) + "ping" -> validate_ping_config(changeset, config) _ -> changeset end end @@ -152,6 +153,14 @@ defmodule Towerops.Monitoring.Check do end end + defp validate_ping_config(changeset, config) do + if Map.has_key?(config, "host") do + changeset + else + add_error(changeset, :config, "Ping check requires 'host' in config") + end + end + defp validate_source_type(changeset) do source_type = get_field(changeset, :source_type) diff --git a/lib/towerops/monitoring/executors/ping_executor.ex b/lib/towerops/monitoring/executors/ping_executor.ex new file mode 100644 index 00000000..78372768 --- /dev/null +++ b/lib/towerops/monitoring/executors/ping_executor.ex @@ -0,0 +1,125 @@ +defmodule Towerops.Monitoring.Executors.PingExecutor do + @moduledoc """ + Executes ICMP ping checks using the system `ping` command. + + Config format: + %{ + "host" => "10.0.0.1", + "count" => 3 # optional, default: 3 + } + """ + + require Logger + + @default_count 3 + @default_timeout 5000 + + @doc """ + Executes a ping check. + + Returns: + - {:ok, response_time_ms, output} on success + - {:error, reason} on failure + """ + def execute(config, timeout_ms \\ @default_timeout) do + host = Map.fetch!(config, "host") + count = config |> Map.get("count", @default_count) |> max(1) |> min(10) + + with :ok <- validate_host(host) do + args = build_args(host, count, timeout_ms) + + # Use timeout_ms plus buffer for the system call + system_timeout = timeout_ms + count * 1000 + 2000 + + case System.cmd("ping", args, stderr_to_stdout: true, timeout: system_timeout) do + {output, 0} -> + parse_output(output) + + {output, _exit_code} -> + # Non-zero exit — try to parse anyway (partial loss still has stats) + case parse_output(output) do + {:ok, _, _} = success -> success + {:error, _} = error -> error + end + end + end + rescue + e -> + Logger.error("Ping check exception: #{inspect(e)}") + {:error, "Exception: #{Exception.message(e)}"} + catch + :exit, {:timeout, _} -> + {:error, "Ping command timed out"} + end + + @doc """ + Parses ping command output to extract RTT and packet loss. + """ + def parse_output(output) when is_binary(output) and byte_size(output) > 0 do + with {:ok, loss_pct, packets_info} <- parse_packet_loss(output), + {:ok, avg_ms} <- parse_rtt(output) do + if loss_pct >= 100.0 do + {:error, "100% packet loss"} + else + summary = "#{packets_info}, #{format_loss(loss_pct)} loss, avg #{avg_ms}ms" + {:ok, avg_ms, summary} + end + end + end + + def parse_output(_), do: {:error, "No ping output"} + + defp validate_host(host) do + # Only allow valid hostnames and IP addresses — no shell metacharacters + if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do + :ok + else + {:error, "Invalid host: contains disallowed characters"} + end + end + + defp build_args(host, count, timeout_ms) do + count_str = Integer.to_string(count) + + case :os.type() do + {:unix, :darwin} -> + # macOS: -W is in milliseconds + ["-c", count_str, "-W", Integer.to_string(timeout_ms), host] + + {:unix, _} -> + # Linux: -w is in seconds (total deadline) + deadline = max(div(timeout_ms, 1000), count + 1) + ["-c", count_str, "-w", Integer.to_string(deadline), host] + end + end + + defp parse_packet_loss(output) do + # Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss" + # or "3 packets transmitted, 3 received, 0% packet loss" + case Regex.run(~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received, ([\d.]+)% packet loss/, output) do + [_, transmitted, _received, loss_str] -> + {loss_pct, _} = Float.parse(loss_str) + {:ok, loss_pct, "#{transmitted} packets"} + + nil -> + {:error, "Could not parse packet loss from ping output"} + end + end + + defp parse_rtt(output) do + # macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms" + # Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms" + case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do + [_, avg_str] -> + {avg_ms, _} = Float.parse(avg_str) + {:ok, avg_ms} + + nil -> + # No RTT stats (100% loss case) + {:error, "100% packet loss"} + end + end + + defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%" + defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%" +end diff --git a/lib/towerops/workers/check_executor_worker.ex b/lib/towerops/workers/check_executor_worker.ex index 332b5324..e95e9a11 100644 --- a/lib/towerops/workers/check_executor_worker.ex +++ b/lib/towerops/workers/check_executor_worker.ex @@ -28,6 +28,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do alias Towerops.Monitoring alias Towerops.Monitoring.Executors.DnsExecutor alias Towerops.Monitoring.Executors.HttpExecutor + alias Towerops.Monitoring.Executors.PingExecutor alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor alias Towerops.Monitoring.Executors.SnmpProcessorExecutor alias Towerops.Monitoring.Executors.SnmpSensorExecutor @@ -86,7 +87,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do defp dispatch_executor(%{check_type: "http"} = check), do: execute_http_check(check) defp dispatch_executor(%{check_type: "tcp"} = check), do: execute_tcp_check(check) defp dispatch_executor(%{check_type: "dns"} = check), do: execute_dns_check(check) - defp dispatch_executor(%{check_type: "ping"}), do: {:error, "Ping executor not yet implemented"} + defp dispatch_executor(%{check_type: "ping"} = check), do: execute_ping_check(check) defp dispatch_executor(%{check_type: type}), do: {:error, "Unknown check type: #{type}"} defp record_result({:ok, %{value: value, status: status, output: output, response_time_ms: time}}, check) do @@ -171,6 +172,23 @@ defmodule Towerops.Workers.CheckExecutorWorker do end end + # Adapter for Ping executor which returns different format + defp execute_ping_check(check) do + case PingExecutor.execute(check.config, check.timeout_ms) do + {:ok, response_time, output} -> + {:ok, + %{ + value: nil, + status: 0, + output: output, + response_time_ms: response_time + }} + + {:error, reason} -> + {:error, reason} + end + end + defp schedule_next_check(check) do # Calculate staggered offset based on check ID offset = PollingOffset.calculate_offset(check.id, check.interval_seconds) diff --git a/lib/towerops_web/controllers/api/v1/checks_controller.ex b/lib/towerops_web/controllers/api/v1/checks_controller.ex new file mode 100644 index 00000000..b49bde9e --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/checks_controller.ex @@ -0,0 +1,199 @@ +defmodule ToweropsWeb.Api.V1.ChecksController do + @moduledoc """ + API controller for managing service checks (HTTP, TCP, DNS, ping). + + All endpoints require API token authentication and operations are scoped + to the organization associated with the token. Only service check types + (http, tcp, dns, ping) are exposed — SNMP checks are managed internally. + """ + use ToweropsWeb, :controller + + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + + alias Towerops.Monitoring + alias Towerops.Monitoring.Check + alias ToweropsWeb.ScopedResource + + @service_check_types ~w(http tcp dns ping) + + @doc """ + GET /api/v1/checks + + Lists service checks for the authenticated organization. + Optional query params: `check_type`, `device_id`. + """ + def index(conn, params) do + organization_id = conn.assigns.current_organization_id + + opts = + [] + |> maybe_add_opt(:device_id, params["device_id"]) + |> maybe_add_opt(:check_type, params["check_type"]) + + checks = + organization_id + |> Monitoring.list_checks(opts) + |> Enum.filter(&(&1.check_type in @service_check_types)) + |> Enum.map(&format_check/1) + + json(conn, %{checks: checks}) + end + + @doc """ + POST /api/v1/checks + + Creates a new service check for the authenticated organization. + """ + def create(conn, %{"check" => check_params}) do + organization_id = conn.assigns.current_organization_id + + check_type = Map.get(check_params, "check_type", "") + + if check_type in @service_check_types do + attrs = Map.put(check_params, "organization_id", organization_id) + + case Monitoring.create_check(attrs) do + {:ok, check} -> + if check.enabled, do: Monitoring.schedule_check(check) + + conn + |> put_status(:created) + |> json(format_check(check)) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + else + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"}) + end + end + + def create(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing 'check' parameter"}) + end + + @doc """ + GET /api/v1/checks/:id + + Gets a single check by ID. + """ + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(Check, id, organization_id) do + {:ok, check} -> + json(conn, format_check(check)) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Access denied to this check"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Check not found"}) + end + end + + @doc """ + PATCH /api/v1/checks/:id + + Updates a check. + """ + def update(conn, %{"id" => id, "check" => check_params}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(Check, id, organization_id) do + {:ok, check} -> + case Monitoring.update_check(check, check_params) do + {:ok, updated_check} -> + json(conn, format_check(updated_check)) + + {: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 check"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Check not found"}) + end + end + + def update(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing 'check' parameter"}) + end + + @doc """ + DELETE /api/v1/checks/:id + + Deletes a check and cancels any scheduled jobs. + """ + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case ScopedResource.fetch(Check, id, organization_id) do + {:ok, check} -> + case Monitoring.delete_check(check) do + {:ok, _check} -> + 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 check"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Check not found"}) + end + end + + defp format_check(check) do + %{ + id: check.id, + name: check.name, + check_type: check.check_type, + description: check.description, + enabled: check.enabled, + device_id: check.device_id, + agent_token_id: check.agent_token_id, + interval_seconds: check.interval_seconds, + timeout_ms: check.timeout_ms, + retry_interval_seconds: check.retry_interval_seconds, + max_check_attempts: check.max_check_attempts, + config: check.config, + current_state: check.current_state, + current_state_type: check.current_state_type, + last_check_at: check.last_check_at, + inserted_at: check.inserted_at + } + end + + defp maybe_add_opt(opts, _key, nil), do: opts + defp maybe_add_opt(opts, _key, ""), do: opts + defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex index 2c86ca3f..49e2d23f 100644 --- a/lib/towerops_web/controllers/api_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -130,6 +130,15 @@ Integrations +
  • + + Checks + +
  • + +
    +

    + Checks +

    +

    + Service checks monitor the availability and responsiveness of network services. Supported check types: HTTP, TCP, DNS, and ping. SNMP checks are managed internally and are not exposed via this API. +

    + + +
    +

    The check model

    +
    +
    +
    +
    + id + + string + +
    +
    + Unique identifier for the check (UUID). +
    +
    +
    +
    + name + + string + +
    +
    + The name of the check. +
    +
    +
    +
    + check_type + + string + +
    +
    + The type of check: http, tcp, dns, or ping. +
    +
    +
    +
    + enabled + + boolean + +
    +
    + Whether the check is enabled and scheduled to run. +
    +
    +
    +
    + config + + object + +
    +
    + Type-specific configuration. HTTP: url, method, expected_status, verify_ssl, follow_redirects, regex. TCP: host, port, send, expect. DNS: hostname, record_type, server, expected. Ping: host, count. +
    +
    +
    +
    + interval_seconds + + integer + +
    +
    + How often the check runs, in seconds. Default: 60. +
    +
    +
    +
    + current_state + + integer + +
    +
    + Current check state: 0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN. +
    +
    +
    +
    + current_state_type + + string + +
    +
    + Current state type: soft or hard. +
    +
    +
    +
    +
    + +
    + + +
    +
    +

    List all checks

    + + GET + + /api/v1/checks +
    + +
    +
    +

    + Lists all service checks for the authenticated organization. Only returns HTTP, TCP, DNS, and ping checks. +

    + +

    + Optional query parameters +

    +
    +
    +
    + check_type + + string + +
    +
    + Filter by check type: http, tcp, dns, or ping. +
    +
    +
    +
    + device_id + + string + +
    +
    + Filter checks by device UUID. +
    +
    +
    +
    + +
    +
    +
    +

    Request

    +
    +
    <%= raw(~S"""
    +curl -G https://towerops.net/api/v1/checks \
    +  -H "Authorization: Bearer {token}" \
    +  -d check_type=http
    +""") %>
    +
    + +
    +
    +

    Response

    +
    +
    <%= raw(~S"""
    +{
    +  "checks": [
    +    {
    +      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    +      "name": "Web Health Check",
    +      "check_type": "http",
    +      "enabled": true,
    +      "config": {"url": "https://example.com/health", "expected_status": 200},
    +      "interval_seconds": 60,
    +      "current_state": 0,
    +      "current_state_type": "hard",
    +      "last_check_at": "2026-03-16T12:00:00Z",
    +      "inserted_at": "2026-03-15T10:00:00Z"
    +    }
    +  ]
    +}
    +""") %>
    +
    +
    +
    +
    + +
    + + +
    +
    +

    Create a check

    + + POST + + /api/v1/checks +
    + +
    +
    +

    + Creates a new service check for the authenticated organization. The check is automatically scheduled if enabled. +

    + +

    + Required parameters +

    +
    +
    +
    + name + + string + +
    +
    + The name of the check. +
    +
    +
    +
    + check_type + + string + +
    +
    + One of: http, tcp, dns, ping. +
    +
    +
    +
    + config + + object + +
    +
    + Type-specific configuration object. See check model for available fields. +
    +
    +
    + +

    + Optional parameters +

    +
    +
    +
    + enabled + + boolean + +
    +
    + Whether the check is enabled. Default: true. +
    +
    +
    +
    + device_id + + string + +
    +
    + Associate the check with a device. +
    +
    +
    +
    + interval_seconds + + integer + +
    +
    + How often the check runs. Default: 60. +
    +
    +
    +
    + +
    +
    +
    +

    Request

    +
    +
    <%= raw(~S"""
    +curl https://towerops.net/api/v1/checks \
    +  -H "Authorization: Bearer {token}" \
    +  -H "Content-Type: application/json" \
    +  -d '{"check": {"name": "Web Health", "check_type": "http", "config": {"url": "https://example.com/health", "expected_status": 200}}}'
    +""") %>
    +
    + +
    +
    +

    Response 201 Created

    +
    +
    <%= raw(~S"""
    +{
    +  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    +  "name": "Web Health",
    +  "check_type": "http",
    +  "enabled": true,
    +  "config": {"url": "https://example.com/health", "expected_status": 200},
    +  "interval_seconds": 60,
    +  "timeout_ms": 5000,
    +  "current_state": null,
    +  "inserted_at": "2026-03-16T10:00:00Z"
    +}
    +""") %>
    +
    +
    +
    +
    + +
    + + +
    +
    +

    Retrieve a check

    + + GET + + /api/v1/checks/:id +
    + +
    +
    +

    + Retrieves a single check by its UUID. +

    +
    + +
    +
    +
    +

    Request

    +
    +
    <%= raw(~S"""
    +curl https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
    +  -H "Authorization: Bearer {token}"
    +""") %>
    +
    +
    +
    +
    + +
    + + +
    +
    +

    Update a check

    + + PATCH + + /api/v1/checks/:id +
    + +
    +
    +

    + Updates an existing check. Only the provided fields will be updated. +

    +
    + +
    +
    +
    +

    Request

    +
    +
    <%= raw(~S"""
    +curl -X PATCH https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
    +  -H "Authorization: Bearer {token}" \
    +  -H "Content-Type: application/json" \
    +  -d '{"check": {"name": "Updated Name", "interval_seconds": 120}}'
    +""") %>
    +
    +
    +
    +
    + +
    + + +
    +
    +

    Delete a check

    + + DELETE + + /api/v1/checks/:id +
    + +
    +
    +

    + Deletes a check and cancels any scheduled executions. +

    +
    + +
    +
    +
    +

    Request

    +
    +
    <%= raw(~S"""
    +curl -X DELETE https://towerops.net/api/v1/checks/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
    +  -H "Authorization: Bearer {token}"
    +""") %>
    +
    + +
    +
    +

    Response

    +
    +
    <%= raw(~S"""
    +{
    +  "success": true
    +}
    +""") %>
    +
    +
    +
    +
    +
    + +
    +

    diff --git a/lib/towerops_web/graphql/resolvers/check.ex b/lib/towerops_web/graphql/resolvers/check.ex new file mode 100644 index 00000000..7ee44523 --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/check.ex @@ -0,0 +1,90 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Check do + @moduledoc "GraphQL resolvers for service check queries and mutations." + + alias Towerops.Monitoring + alias Towerops.Monitoring.Check + alias ToweropsWeb.GraphQL.Resolvers.Helpers + alias ToweropsWeb.ScopedResource + + @service_check_types ~w(http tcp dns ping) + + def list(_parent, args, %{context: %{organization_id: org_id}}) do + opts = + [] + |> maybe_add_opt(:device_id, args[:device_id]) + |> maybe_add_opt(:check_type, args[:check_type]) + + checks = + org_id + |> Monitoring.list_checks(opts) + |> Enum.filter(&(&1.check_type in @service_check_types)) + + {:ok, checks} + end + + def list(_parent, _args, _resolution), do: Helpers.authentication_error() + + def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + fetch_org_check(id, org_id) + end + + def get(_parent, _args, _resolution), do: Helpers.authentication_error() + + def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do + attrs = + input + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("organization_id", org_id) + + check_type = Map.get(attrs, "check_type", "") + + if check_type in @service_check_types do + case Monitoring.create_check(attrs) do + {:ok, check} -> + if check.enabled, do: Monitoring.schedule_check(check) + {:ok, check} + + {:error, changeset} -> + {:error, Helpers.format_changeset_errors(changeset)} + end + else + {:error, "Invalid check type '#{check_type}'. Must be one of: #{Enum.join(@service_check_types, ", ")}"} + 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, check} <- fetch_org_check(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case Monitoring.update_check(check, 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, check} <- fetch_org_check(id, org_id) do + case Monitoring.delete_check(check) do + {:ok, _} -> {:ok, %{success: true, message: "Check deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete check"}} + end + end + end + + def delete(_parent, _args, _resolution), do: Helpers.authentication_error() + + defp fetch_org_check(id, org_id) do + case ScopedResource.fetch(Check, id, org_id) do + {:ok, check} -> {:ok, check} + {:error, _reason} -> {:error, "Check not found"} + end + end + + defp maybe_add_opt(opts, _key, nil), do: opts + defp maybe_add_opt(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 6219ba8f..11cd0cea 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -24,6 +24,7 @@ defmodule ToweropsWeb.GraphQL.Schema do import_types(ToweropsWeb.GraphQL.Types.Schedule) import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy) import_types(ToweropsWeb.GraphQL.Types.MaintenanceWindow) + import_types(ToweropsWeb.GraphQL.Types.Check) import_types(ToweropsWeb.GraphQL.Types.TimeSeries) query do @@ -80,6 +81,22 @@ defmodule ToweropsWeb.GraphQL.Schema do resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.get/3) end + # Service Checks + field :checks, list_of(:check) do + arg(:organization_id, :id) + arg(:device_id, :id) + arg(:check_type, :string) + middleware(OrganizationScope) + resolve(&ToweropsWeb.GraphQL.Resolvers.Check.list/3) + end + + field :check, :check do + arg(:organization_id, :id) + arg(:id, non_null(:id)) + middleware(OrganizationScope) + resolve(&ToweropsWeb.GraphQL.Resolvers.Check.get/3) + end + # Agents field :agents, list_of(:agent) do arg(:organization_id, :id) @@ -260,6 +277,23 @@ defmodule ToweropsWeb.GraphQL.Schema do resolve(&ToweropsWeb.GraphQL.Resolvers.Site.delete/3) end + # Check CRUD + field :create_check, :check do + arg(:input, non_null(:check_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Check.create/3) + end + + field :update_check, :check do + arg(:id, non_null(:id)) + arg(:input, non_null(:check_input)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Check.update/3) + end + + field :delete_check, :delete_result do + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Check.delete/3) + end + # Alert actions field :acknowledge_alert, :alert do arg(:id, non_null(:id)) diff --git a/lib/towerops_web/graphql/types/check.ex b/lib/towerops_web/graphql/types/check.ex new file mode 100644 index 00000000..693094a5 --- /dev/null +++ b/lib/towerops_web/graphql/types/check.ex @@ -0,0 +1,37 @@ +defmodule ToweropsWeb.GraphQL.Types.Check do + @moduledoc "GraphQL types for service checks." + use Absinthe.Schema.Notation + + object :check do + field :id, :id + field :name, :string + field :check_type, :string + field :description, :string + field :enabled, :boolean + field :device_id, :id + field :agent_token_id, :id + field :interval_seconds, :integer + field :timeout_ms, :integer + field :retry_interval_seconds, :integer + field :max_check_attempts, :integer + field :config, :json + field :current_state, :integer + field :current_state_type, :string + field :last_check_at, :string + field :inserted_at, :string + end + + input_object :check_input do + field :name, :string + field :check_type, :string + field :description, :string + field :enabled, :boolean + field :device_id, :id + field :agent_token_id, :id + field :interval_seconds, :integer + field :timeout_ms, :integer + field :retry_interval_seconds, :integer + field :max_check_attempts, :integer + field :config, :json + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index c1cb4f6b..b67fe064 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -156,6 +156,7 @@ defmodule ToweropsWeb.Router do resources "/sites", SitesController, except: [:new, :edit] resources "/devices", DevicesController, except: [:new, :edit] + resources "/checks", ChecksController, except: [:new, :edit] resources "/agents", AgentsController, except: [:new, :edit] diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 0763f0ce..d5f7da37 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,3 +1,9 @@ +2026-03-16 — Service Checks API +* New REST API for managing service checks (HTTP, TCP, DNS, ping) +* New GraphQL queries and mutations for service check management +* Ping monitoring now fully operational with packet loss and RTT tracking +* API documentation updated with service checks endpoints + 2026-03-14 — Network Map Improvements * Node labels are now larger and easier to read * Sites no longer overlap — each site group is laid out in its own section diff --git a/test/towerops/monitoring/executors/ping_executor_test.exs b/test/towerops/monitoring/executors/ping_executor_test.exs new file mode 100644 index 00000000..310cd892 --- /dev/null +++ b/test/towerops/monitoring/executors/ping_executor_test.exs @@ -0,0 +1,137 @@ +defmodule Towerops.Monitoring.Executors.PingExecutorTest do + use ExUnit.Case, async: true + + alias Towerops.Monitoring.Executors.PingExecutor + + describe "execute/2" do + test "returns success with valid ping output" do + config = %{"host" => "127.0.0.1", "count" => 1} + + case PingExecutor.execute(config, 10_000) do + {:ok, response_time, output} -> + assert is_number(response_time) + assert response_time >= 0 + assert is_binary(output) + assert output =~ "packet" + + {:error, reason} -> + # Ping to localhost might fail in some CI environments + assert is_binary(reason) + end + end + + test "defaults count to 3 when not provided" do + config = %{"host" => "127.0.0.1"} + + # Should use default count of 3 — just verify it doesn't crash + result = PingExecutor.execute(config, 10_000) + assert match?({:ok, _, _}, result) or match?({:error, _}, result) + end + + test "returns error for unreachable host" do + # RFC 5737 TEST-NET address — should be unreachable + config = %{"host" => "192.0.2.1", "count" => 1} + + case PingExecutor.execute(config, 6000) do + {:error, reason} -> + assert is_binary(reason) + + {:ok, _time, output} -> + # Some networks may route this, that's fine + assert is_binary(output) + end + end + + test "returns error for invalid host" do + config = %{"host" => "definitely-not-a-real-host-12345.invalid", "count" => 1} + + assert {:error, reason} = PingExecutor.execute(config, 6000) + assert is_binary(reason) + end + + test "sanitizes host to prevent command injection" do + config = %{"host" => "127.0.0.1; rm -rf /", "count" => 1} + + assert {:error, reason} = PingExecutor.execute(config, 5000) + assert reason =~ "Invalid host" + end + + test "sanitizes count to positive integer" do + config = %{"host" => "127.0.0.1", "count" => -1} + + # Should clamp or reject negative count + result = PingExecutor.execute(config, 10_000) + assert match?({:ok, _, _}, result) or match?({:error, _}, result) + end + end + + describe "parse_output/1" do + test "parses macOS ping output" do + output = """ + PING 127.0.0.1 (127.0.0.1): 56 data bytes + 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.042 ms + 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.068 ms + 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.066 ms + + --- 127.0.0.1 ping statistics --- + 3 packets transmitted, 3 packets received, 0.0% packet loss + round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms + """ + + assert {:ok, avg_ms, summary} = PingExecutor.parse_output(output) + assert_in_delta avg_ms, 0.059, 0.001 + assert summary =~ "3 packets" + assert summary =~ "0% loss" + end + + test "parses Linux ping output" do + output = """ + PING 10.0.0.1 (10.0.0.1) 56(84) bytes of data. + 64 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=1.23 ms + 64 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=1.45 ms + 64 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=1.12 ms + + --- 10.0.0.1 ping statistics --- + 3 packets transmitted, 3 received, 0% packet loss, time 2003ms + rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms + """ + + assert {:ok, avg_ms, summary} = PingExecutor.parse_output(output) + assert_in_delta avg_ms, 1.266, 0.001 + assert summary =~ "3 packets" + assert summary =~ "0% loss" + end + + test "returns error for 100% packet loss" do + output = """ + PING 192.0.2.1 (192.0.2.1): 56 data bytes + + --- 192.0.2.1 ping statistics --- + 3 packets transmitted, 0 packets received, 100.0% packet loss + """ + + assert {:error, reason} = PingExecutor.parse_output(output) + assert reason =~ "100" + assert reason =~ "packet loss" + end + + test "returns error for partial packet loss" do + output = """ + PING 10.0.0.1 (10.0.0.1): 56 data bytes + 64 bytes from 10.0.0.1: icmp_seq=0 ttl=64 time=1.23 ms + + --- 10.0.0.1 ping statistics --- + 3 packets transmitted, 1 packets received, 66.7% packet loss + round-trip min/avg/max/stddev = 1.230/1.230/1.230/0.000 ms + """ + + # Partial loss should still report as warning with the data + result = PingExecutor.parse_output(output) + assert match?({:ok, _, _}, result) or match?({:error, _}, result) + end + + test "returns error for empty output" do + assert {:error, _reason} = PingExecutor.parse_output("") + end + end +end diff --git a/test/towerops_web/controllers/api/v1/checks_controller_test.exs b/test/towerops_web/controllers/api/v1/checks_controller_test.exs new file mode 100644 index 00000000..4d25baf3 --- /dev/null +++ b/test/towerops_web/controllers/api/v1/checks_controller_test.exs @@ -0,0 +1,425 @@ +defmodule ToweropsWeb.Api.V1.ChecksControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.Monitoring + + 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, + raw_token: raw_token + } + end + + defp create_check(organization_id, attrs \\ %{}) do + defaults = %{ + name: "Test Check", + check_type: "http", + organization_id: organization_id, + config: %{"url" => "https://example.com"} + } + + {:ok, check} = Monitoring.create_check(Map.merge(defaults, attrs)) + check + end + + describe "index/2" do + test "lists service checks for authenticated organization", %{ + conn: conn, + organization: organization + } do + check1 = + create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}}) + + check2 = + create_check(organization.id, %{ + name: "TCP Check", + check_type: "tcp", + config: %{"host" => "10.0.0.1", "port" => 80} + }) + + conn = get(conn, ~p"/api/v1/checks") + + assert %{"checks" => checks} = json_response(conn, 200) + assert length(checks) == 2 + + check_ids = Enum.map(checks, & &1["id"]) + assert check1.id in check_ids + assert check2.id in check_ids + + check_json = List.first(checks) + assert Map.has_key?(check_json, "id") + assert Map.has_key?(check_json, "name") + assert Map.has_key?(check_json, "check_type") + assert Map.has_key?(check_json, "config") + assert Map.has_key?(check_json, "enabled") + assert Map.has_key?(check_json, "inserted_at") + end + + test "excludes SNMP checks from listing", %{conn: conn, organization: organization} do + _http = + create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}}) + + # Create an SNMP check — should not appear + {:ok, _snmp} = + Monitoring.create_check(%{ + name: "SNMP Sensor", + check_type: "snmp_sensor", + organization_id: organization.id, + config: %{"oid" => "1.3.6.1.2.1.1.3.0"} + }) + + conn = get(conn, ~p"/api/v1/checks") + + assert %{"checks" => checks} = json_response(conn, 200) + assert length(checks) == 1 + assert List.first(checks)["check_type"] == "http" + end + + test "filters by check_type query param", %{conn: conn, organization: organization} do + _http = + create_check(organization.id, %{name: "HTTP Check", check_type: "http", config: %{"url" => "https://example.com"}}) + + _tcp = + create_check(organization.id, %{ + name: "TCP Check", + check_type: "tcp", + config: %{"host" => "10.0.0.1", "port" => 80} + }) + + conn = get(conn, ~p"/api/v1/checks?check_type=tcp") + + assert %{"checks" => checks} = json_response(conn, 200) + assert length(checks) == 1 + assert List.first(checks)["check_type"] == "tcp" + end + + test "returns empty list when organization has no checks", %{conn: conn} do + conn = get(conn, ~p"/api/v1/checks") + + assert %{"checks" => []} = json_response(conn, 200) + end + + test "does not return checks from other organizations", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + _other_check = create_check(other_org.id) + + conn = get(conn, ~p"/api/v1/checks") + + assert %{"checks" => checks} = json_response(conn, 200) + assert Enum.empty?(checks) + end + end + + describe "create/2" do + test "creates HTTP check with valid attributes", %{conn: conn, organization: organization} do + params = %{ + "check" => %{ + "name" => "Web Health", + "check_type" => "http", + "config" => %{ + "url" => "https://example.com/health", + "expected_status" => 200 + } + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{ + "id" => id, + "name" => "Web Health", + "check_type" => "http", + "enabled" => true + } = json_response(conn, 201) + + assert is_binary(id) + + check = Monitoring.get_check!(id) + assert check.organization_id == organization.id + end + + test "creates TCP check with valid attributes", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "MySQL Port", + "check_type" => "tcp", + "config" => %{ + "host" => "10.0.0.5", + "port" => 3306 + } + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{ + "id" => _id, + "name" => "MySQL Port", + "check_type" => "tcp" + } = json_response(conn, 201) + end + + test "creates DNS check with valid attributes", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "DNS Resolution", + "check_type" => "dns", + "config" => %{ + "hostname" => "google.com", + "dns_server" => "8.8.8.8", + "record_type" => "A" + } + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{ + "id" => _id, + "name" => "DNS Resolution", + "check_type" => "dns" + } = json_response(conn, 201) + end + + test "creates ping check with valid attributes", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "Gateway Ping", + "check_type" => "ping", + "config" => %{ + "host" => "10.0.0.1", + "count" => 3 + } + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{ + "id" => _id, + "name" => "Gateway Ping", + "check_type" => "ping" + } = json_response(conn, 201) + end + + test "returns 422 when config is invalid for check type", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "Bad HTTP Check", + "check_type" => "http", + "config" => %{} + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{"errors" => _errors} = json_response(conn, 422) + end + + test "returns 422 when name is missing", %{conn: conn} do + params = %{ + "check" => %{ + "check_type" => "http", + "config" => %{"url" => "https://example.com"} + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{"errors" => errors} = json_response(conn, 422) + assert Map.has_key?(errors, "name") + end + + test "returns 400 when check parameter is missing", %{conn: conn} do + conn = post(conn, ~p"/api/v1/checks", %{}) + + assert %{"error" => "Missing 'check' parameter"} = json_response(conn, 400) + end + + test "rejects SNMP check types", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "SNMP Check", + "check_type" => "snmp_sensor", + "config" => %{"oid" => "1.3.6.1.2.1.1.3.0"} + } + } + + conn = post(conn, ~p"/api/v1/checks", params) + + assert %{"error" => _} = json_response(conn, 400) + end + end + + describe "show/2" do + test "returns check when it belongs to organization", %{ + conn: conn, + organization: organization + } do + check = create_check(organization.id, %{name: "Show Me"}) + + conn = get(conn, ~p"/api/v1/checks/#{check.id}") + + assert %{ + "id" => id, + "name" => "Show Me", + "check_type" => "http" + } = json_response(conn, 200) + + assert id == check.id + end + + test "returns 404 when check does not exist", %{conn: conn} do + conn = get(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}") + + assert %{"error" => "Check not found"} = json_response(conn, 404) + end + + test "returns 403 when check belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_check = create_check(other_org.id) + + conn = get(conn, ~p"/api/v1/checks/#{other_check.id}") + + assert %{"error" => "Access denied to this check"} = json_response(conn, 403) + end + end + + describe "update/2" do + test "updates check with valid attributes", %{conn: conn, organization: organization} do + check = create_check(organization.id, %{name: "Original"}) + + params = %{ + "check" => %{ + "name" => "Updated", + "interval_seconds" => 120 + } + } + + conn = patch(conn, ~p"/api/v1/checks/#{check.id}", params) + + assert %{ + "id" => id, + "name" => "Updated", + "interval_seconds" => 120 + } = json_response(conn, 200) + + assert id == check.id + end + + test "returns 404 when check does not exist", %{conn: conn} do + params = %{ + "check" => %{ + "name" => "Updated" + } + } + + conn = patch(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}", params) + + assert %{"error" => "Check not found"} = json_response(conn, 404) + end + + test "returns 403 when check belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_check = create_check(other_org.id) + + params = %{"check" => %{"name" => "Hacked"}} + + conn = patch(conn, ~p"/api/v1/checks/#{other_check.id}", params) + + assert %{"error" => "Access denied to this check"} = json_response(conn, 403) + end + + test "returns 422 with invalid params", %{conn: conn, organization: organization} do + check = create_check(organization.id) + + params = %{"check" => %{"name" => ""}} + + conn = patch(conn, ~p"/api/v1/checks/#{check.id}", params) + + assert %{"errors" => errors} = json_response(conn, 422) + assert Map.has_key?(errors, "name") + end + + test "returns 400 when check parameter is missing", %{conn: conn, organization: organization} do + check = create_check(organization.id) + + conn = patch(conn, ~p"/api/v1/checks/#{check.id}", %{}) + + assert %{"error" => "Missing 'check' parameter"} = json_response(conn, 400) + end + end + + describe "delete/2" do + test "deletes check successfully", %{conn: conn, organization: organization} do + check = create_check(organization.id, %{name: "Doomed"}) + + conn = delete(conn, ~p"/api/v1/checks/#{check.id}") + + assert %{"success" => true} = json_response(conn, 200) + + assert Monitoring.get_check(check.id) == nil + end + + test "returns 404 when check does not exist", %{conn: conn} do + conn = delete(conn, ~p"/api/v1/checks/#{Ecto.UUID.generate()}") + + assert %{"error" => "Check not found"} = json_response(conn, 404) + end + + test "returns 403 when check belongs to different organization", %{conn: conn} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + other_check = create_check(other_org.id) + + conn = delete(conn, ~p"/api/v1/checks/#{other_check.id}") + + assert %{"error" => "Access denied to this check"} = 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/checks") + + 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/checks") + + assert %{"error" => _message} = json_response(conn, 401) + end + end +end diff --git a/test/towerops_web/graphql/resolvers/check_test.exs b/test/towerops_web/graphql/resolvers/check_test.exs new file mode 100644 index 00000000..c8333af6 --- /dev/null +++ b/test/towerops_web/graphql/resolvers/check_test.exs @@ -0,0 +1,275 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.CheckTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.Monitoring + + 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("content-type", "application/json") + + %{conn: conn, organization: organization} + end + + defp graphql_query(conn, query, variables \\ %{}) do + conn + |> post("/api/graphql", Jason.encode!(%{query: query, variables: variables})) + |> json_response(200) + end + + defp create_check(organization_id, attrs) do + defaults = %{ + name: "Test Check", + check_type: "http", + organization_id: organization_id, + config: %{"url" => "https://example.com"} + } + + {:ok, check} = Monitoring.create_check(Map.merge(defaults, attrs)) + check + end + + describe "checks query" do + test "lists service checks", %{conn: conn, organization: org} do + _http = create_check(org.id, %{name: "HTTP Check"}) + _tcp = create_check(org.id, %{name: "TCP Check", check_type: "tcp", config: %{"host" => "10.0.0.1", "port" => 80}}) + + query = """ + { + checks { + id + name + checkType + enabled + } + } + """ + + result = graphql_query(conn, query) + + assert %{"data" => %{"checks" => checks}} = result + assert length(checks) == 2 + + names = Enum.map(checks, & &1["name"]) + assert "HTTP Check" in names + assert "TCP Check" in names + end + + test "filters by check_type", %{conn: conn, organization: org} do + _http = create_check(org.id, %{name: "HTTP Check"}) + _tcp = create_check(org.id, %{name: "TCP Check", check_type: "tcp", config: %{"host" => "10.0.0.1", "port" => 80}}) + + query = """ + query($type: String) { + checks(checkType: $type) { + name + checkType + } + } + """ + + result = graphql_query(conn, query, %{"type" => "tcp"}) + + assert %{"data" => %{"checks" => [check]}} = result + assert check["name"] == "TCP Check" + end + + test "excludes SNMP checks", %{conn: conn, organization: org} do + _http = create_check(org.id, %{name: "HTTP Check"}) + + {:ok, _snmp} = + Monitoring.create_check(%{ + name: "SNMP Sensor", + check_type: "snmp_sensor", + organization_id: org.id, + config: %{"oid" => "1.3.6.1.2.1.1.3.0"} + }) + + query = """ + { + checks { + name + checkType + } + } + """ + + result = graphql_query(conn, query) + + assert %{"data" => %{"checks" => checks}} = result + assert length(checks) == 1 + assert List.first(checks)["checkType"] == "http" + end + end + + describe "check query" do + test "returns a single check", %{conn: conn, organization: org} do + check = create_check(org.id, %{name: "Single Check"}) + + query = """ + query($id: ID!) { + check(id: $id) { + id + name + checkType + config + enabled + intervalSeconds + } + } + """ + + result = graphql_query(conn, query, %{"id" => check.id}) + + assert %{"data" => %{"check" => data}} = result + assert data["id"] == check.id + assert data["name"] == "Single Check" + assert data["checkType"] == "http" + assert data["enabled"] == true + end + + test "returns error for non-existent check", %{conn: conn} do + query = """ + query($id: ID!) { + check(id: $id) { + id + name + } + } + """ + + result = graphql_query(conn, query, %{"id" => Ecto.UUID.generate()}) + + assert %{"errors" => [%{"message" => "Check not found"}]} = result + end + end + + describe "createCheck mutation" do + test "creates an HTTP check", %{conn: conn, organization: org} do + mutation = """ + mutation($input: CheckInput!) { + createCheck(input: $input) { + id + name + checkType + enabled + } + } + """ + + input = %{ + "name" => "New HTTP Check", + "check_type" => "http", + "config" => Jason.encode!(%{"url" => "https://example.com/health"}) + } + + result = graphql_query(conn, mutation, %{"input" => input}) + + assert %{"data" => %{"createCheck" => check}} = result + assert check["name"] == "New HTTP Check" + assert check["checkType"] == "http" + assert check["enabled"] == true + + # Verify it exists in DB + assert Monitoring.get_check(check["id"]).organization_id == org.id + end + + test "returns error for invalid check type", %{conn: conn} do + mutation = """ + mutation($input: CheckInput!) { + createCheck(input: $input) { + id + } + } + """ + + input = %{ + "name" => "Bad Check", + "check_type" => "snmp_sensor", + "config" => Jason.encode!(%{"oid" => "1.3.6.1.2.1.1.3.0"}) + } + + result = graphql_query(conn, mutation, %{"input" => input}) + + assert %{"errors" => [%{"message" => message}]} = result + assert message =~ "Invalid check type" + end + end + + describe "updateCheck mutation" do + test "updates an existing check", %{conn: conn, organization: org} do + check = create_check(org.id, %{name: "Original"}) + + mutation = """ + mutation($id: ID!, $input: CheckInput!) { + updateCheck(id: $id, input: $input) { + id + name + intervalSeconds + } + } + """ + + input = %{"name" => "Updated", "interval_seconds" => 120} + + result = graphql_query(conn, mutation, %{"id" => check.id, "input" => input}) + + assert %{"data" => %{"updateCheck" => updated}} = result + assert updated["name"] == "Updated" + assert updated["intervalSeconds"] == 120 + end + end + + describe "deleteCheck mutation" do + test "deletes a check", %{conn: conn, organization: org} do + check = create_check(org.id, %{name: "Doomed"}) + + mutation = """ + mutation($id: ID!) { + deleteCheck(id: $id) { + success + message + } + } + """ + + result = graphql_query(conn, mutation, %{"id" => check.id}) + + assert %{"data" => %{"deleteCheck" => %{"success" => true}}} = result + assert Monitoring.get_check(check.id) == nil + end + end + + describe "authentication" do + test "returns 401 without auth" do + conn = put_req_header(build_conn(), "content-type", "application/json") + + query = """ + { + checks { + id + } + } + """ + + resp = post(conn, "/api/graphql", Jason.encode!(%{query: query})) + + assert resp.status == 401 + end + end +end