towerops/docs/templates/context-module.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

6.1 KiB

Ecto Context Module Template

Copy-pasteable template for a new Ecto schema + context + test following TowerOps conventions.

Replace __CONTEXT__ with PascalCase context name (e.g., Schedules). Replace __SCHEMA__ with PascalCase schema name (e.g., Schedule). Replace __resource__ with snake_case (e.g., schedule). Replace __resources__ with plural snake_case (e.g., schedules). Replace __table__ with the database table name (e.g., schedules).


lib/towerops/__context__/__resource__.ex (Schema)

defmodule Towerops.__CONTEXT__.__SCHEMA__ do
  @moduledoc """
  Schema for __resources__.

  Describe what this resource represents and its relationships.
  """
  use Ecto.Schema

  import Ecto.Changeset

  alias Towerops.Organizations.Organization

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "__table__" do
    field :name, :string
    field :description, :string
    field :enabled, :boolean, default: true

    belongs_to :organization, Organization

    timestamps()
  end

  @required_fields ~w(name organization_id)a
  @optional_fields ~w(description enabled)a

  def changeset(__resource__, attrs) do
    __resource__
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> validate_length(:name, max: 255)
    |> validate_length(:description, max: 1000)
    |> foreign_key_constraint(:organization_id)
  end
end

lib/towerops/__context__.ex (Context)

defmodule Towerops.__CONTEXT__ do
  @moduledoc """
  Context for managing __resources__.
  """

  import Ecto.Query

  alias Towerops.__CONTEXT__.__SCHEMA__
  alias Towerops.Repo

  @preloads [:organization]

  def create___resource__(attrs) do
    %__SCHEMA__{}
    |> __SCHEMA__.changeset(attrs)
    |> Repo.insert()
  end

  def update___resource__(%__SCHEMA__{} = __resource__, attrs) do
    __resource__
    |> __SCHEMA__.changeset(attrs)
    |> Repo.update()
  end

  def delete___resource__(%__SCHEMA__{} = __resource__) do
    Repo.delete(__resource__)
  end

  def get___resource__!(id) do
    __SCHEMA__
    |> Repo.get!(id)
    |> Repo.preload(@preloads)
  end

  def list___resources__(organization_id, opts \\ []) do
    __SCHEMA__
    |> where([r], r.organization_id == ^organization_id)
    |> apply_filter(opts[:filter])
    |> order_by([r], desc: r.inserted_at)
    |> Repo.all()
    |> Repo.preload(@preloads)
  end

  defp apply_filter(query, :active) do
    where(query, [r], r.enabled == true)
  end

  defp apply_filter(query, :archived) do
    where(query, [r], r.enabled == false)
  end

  defp apply_filter(query, _), do: query
end

Migration

defmodule Towerops.Repo.Migrations.Create__SCHEMA__s do
  use Ecto.Migration

  def change do
    create table(:__table__, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :name, :string, null: false
      add :description, :text
      add :enabled, :boolean, default: true, null: false

      add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
        null: false

      timestamps()
    end

    create index(:__table__, [:organization_id])
  end
end

test/towerops/__context___test.exs (Context Tests)

defmodule Towerops.__CONTEXT__Test do
  use Towerops.DataCase

  alias Towerops.__CONTEXT__
  alias Towerops.__CONTEXT__.__SCHEMA__

  setup do
    user = insert_user()
    {:ok, organization} =
      Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)

    %{organization: organization, user: user}
  end

  describe "create___resource__/1" do
    test "creates with valid attrs", %{organization: org} do
      attrs = %{
        name: "Test __RESOURCE_TITLE__",
        organization_id: org.id
      }

      assert {:ok, %__SCHEMA__{} = __resource__} = __CONTEXT__.create___resource__(attrs)
      assert __resource__.name == "Test __RESOURCE_TITLE__"
      assert __resource__.organization_id == org.id
    end

    test "fails without required fields" do
      assert {:error, changeset} = __CONTEXT__.create___resource__(%{})
      assert errors_on(changeset) |> Map.has_key?(:name)
      assert errors_on(changeset) |> Map.has_key?(:organization_id)
    end
  end

  describe "update___resource__/2" do
    test "updates with valid attrs", %{organization: org} do
      {:ok, __resource__} =
        __CONTEXT__.create___resource__(%{name: "Original", organization_id: org.id})

      assert {:ok, updated} = __CONTEXT__.update___resource__(__resource__, %{name: "Updated"})
      assert updated.name == "Updated"
    end
  end

  describe "delete___resource__/1" do
    test "deletes the resource", %{organization: org} do
      {:ok, __resource__} =
        __CONTEXT__.create___resource__(%{name: "To Delete", organization_id: org.id})

      assert {:ok, _} = __CONTEXT__.delete___resource__(__resource__)
      assert_raise Ecto.NoResultsError, fn -> __CONTEXT__.get___resource__!(__resource__.id) end
    end
  end

  describe "list___resources__/2" do
    test "lists resources for organization", %{organization: org} do
      {:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 1", organization_id: org.id})
      {:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 2", organization_id: org.id})

      __resources__ = __CONTEXT__.list___resources__(org.id)
      assert length(__resources__) == 2
    end

    test "filters by active status", %{organization: org} do
      {:ok, _} = __CONTEXT__.create___resource__(%{name: "Active", organization_id: org.id, enabled: true})
      {:ok, _} = __CONTEXT__.create___resource__(%{name: "Archived", organization_id: org.id, enabled: false})

      active = __CONTEXT__.list___resources__(org.id, filter: :active)
      assert length(active) == 1
      assert hd(active).name == "Active"
    end
  end

  describe "get___resource__!/1" do
    test "returns the resource with preloads", %{organization: org} do
      {:ok, created} = __CONTEXT__.create___resource__(%{name: "Find Me", organization_id: org.id})

      __resource__ = __CONTEXT__.get___resource__!(created.id)
      assert __resource__.name == "Find Me"
      assert __resource__.organization.id == org.id
    end
  end
end