towerops/test/towerops_web/controllers/api/v1/schedules_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

398 lines
12 KiB
Elixir

defmodule ToweropsWeb.Api.V1.SchedulesControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.OnCall
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 schedules for authenticated organization", %{
conn: conn,
organization: organization
} do
schedule1 = schedule_fixture(organization.id, %{name: "Primary"})
schedule2 = schedule_fixture(organization.id, %{name: "Secondary"})
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => schedules} = json_response(conn, 200)
assert length(schedules) == 2
ids = Enum.map(schedules, & &1["id"])
assert schedule1.id in ids
assert schedule2.id in ids
end
test "returns empty list when organization has no schedules", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => []} = json_response(conn, 200)
end
test "does not return schedules from other organizations", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
_other_schedule = schedule_fixture(other_org.id)
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => []} = json_response(conn, 200)
end
end
describe "create/2" do
test "creates schedule with valid attributes", %{conn: conn, organization: organization} do
params = %{
"schedule" => %{
"name" => "On-Call Rotation",
"timezone" => "America/New_York",
"description" => "Primary rotation"
}
}
conn = post(conn, ~p"/api/v1/schedules", params)
assert %{
"id" => id,
"name" => "On-Call Rotation",
"timezone" => "America/New_York",
"description" => "Primary rotation"
} = json_response(conn, 201)
assert is_binary(id)
schedule = OnCall.get_schedule!(id)
assert schedule.organization_id == organization.id
end
test "returns 422 with invalid params", %{conn: conn} do
params = %{"schedule" => %{"name" => ""}}
conn = post(conn, ~p"/api/v1/schedules", params)
assert %{"errors" => errors} = json_response(conn, 422)
assert Map.has_key?(errors, "name") or Map.has_key?(errors, "timezone")
end
test "returns 400 when schedule parameter is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/schedules", %{})
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
end
end
describe "show/2" do
test "returns schedule with layers, members, and overrides", %{
conn: conn,
user: user,
organization: organization
} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
_member = layer_member_fixture(layer.id, user.id)
_override = override_fixture(schedule.id, user.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}")
assert %{
"id" => _,
"name" => _,
"timezone" => _,
"layers" => [layer_json],
"overrides" => [override_json]
} = json_response(conn, 200)
assert %{"members" => [_member_json]} = layer_json
assert Map.has_key?(override_json, "start_time")
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 404 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
conn = get(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "update/2" do
test "updates schedule with valid attributes", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Original"})
params = %{"schedule" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", params)
assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
params = %{"schedule" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}", params)
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 404 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
params = %{"schedule" => %{"name" => "Hacked"}}
conn = patch(conn, ~p"/api/v1/schedules/#{other_schedule.id}", params)
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 400 when schedule parameter is missing", %{
conn: conn,
organization: organization
} do
schedule = schedule_fixture(organization.id)
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", %{})
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
end
end
describe "delete/2" do
test "deletes schedule successfully", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}")
assert %{"success" => true} = json_response(conn, 200)
assert OnCall.get_schedule(schedule.id) == nil
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = delete(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 404 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
conn = delete(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "on_call/2" do
test "returns on-call user for schedule", %{
conn: conn,
user: user,
organization: organization
} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{start_date: ~U[2025-01-01 09:00:00Z]})
_member = layer_member_fixture(layer.id, user.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
assert %{"on_call" => on_call} = json_response(conn, 200)
assert on_call["id"] == user.id
end
test "returns null when no one is on call", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
assert %{"on_call" => nil} = json_response(conn, 200)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/on_call")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "create_layer/2" do
test "creates layer on schedule", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{
"layer" => %{
"name" => "Primary",
"position" => 1,
"rotation_type" => "weekly",
"rotation_interval" => 1,
"handoff_time" => "09:00:00",
"start_date" => "2026-01-01T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers", params)
assert %{"id" => _, "name" => "Primary", "rotation_type" => "weekly"} =
json_response(conn, 201)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
params = %{
"layer" => %{
"name" => "Primary",
"position" => 1,
"rotation_type" => "weekly",
"rotation_interval" => 1,
"handoff_time" => "09:00:00",
"start_date" => "2026-01-01T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/layers", params)
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "update_layer/2" do
test "updates layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
params = %{"layer" => %{"name" => "Updated Layer"}}
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}", params)
assert %{"name" => "Updated Layer"} = json_response(conn, 200)
end
test "returns 404 when layer does not exist", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{"layer" => %{"name" => "Updated"}}
conn =
patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{Ecto.UUID.generate()}", params)
assert %{"error" => "Layer not found"} = json_response(conn, 404)
end
end
describe "delete_layer/2" do
test "deletes layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}")
assert %{"success" => true} = json_response(conn, 200)
end
end
describe "create_member/2" do
test "adds member to layer", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
params = %{
"member" => %{
"user_id" => user.id,
"position" => 0
}
}
conn =
post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members", params)
assert %{"id" => _, "user_id" => uid, "position" => 0} = json_response(conn, 201)
assert uid == user.id
end
end
describe "delete_member/2" do
test "removes member from layer", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
member = layer_member_fixture(layer.id, user.id)
conn =
delete(
conn,
~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members/#{member.id}"
)
assert %{"success" => true} = json_response(conn, 200)
end
end
describe "create_override/2" do
test "creates override on schedule", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{
"override" => %{
"user_id" => user.id,
"start_time" => "2026-03-15T09:00:00Z",
"end_time" => "2026-03-16T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides", params)
assert %{"id" => _, "start_time" => _, "end_time" => _} = json_response(conn, 201)
end
end
describe "delete_override/2" do
test "deletes override", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
override = override_fixture(schedule.id, user.id)
conn =
delete(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides/#{override.id}")
assert %{"success" => true} = json_response(conn, 200)
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/schedules")
assert %{"error" => _message} = json_response(conn, 401)
end
end
end