towerops/test/towerops_web/controllers/api/v1/checks_controller_test.exs
Graham McIntire 095c5d3236 fix: M1 — prevent cross-org resource existence probing via scoped fetch
Use Repo.get_by(schema, id: id, organization_id: organization_id) instead of
Repo.get + pattern match so that resources from wrong orgs return :not_found
instead of :forbidden, preventing org membership discovery.
2026-05-12 13:44:19 -05:00

425 lines
12 KiB
Elixir

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 404 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" => "Check not found"} = json_response(conn, 404)
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 404 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" => "Check not found"} = json_response(conn, 404)
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 404 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" => "Check 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/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