- 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
227 lines
6.1 KiB
Markdown
227 lines
6.1 KiB
Markdown
# 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)
|
|
|
|
```elixir
|
|
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)
|
|
|
|
```elixir
|
|
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
|
|
|
|
```elixir
|
|
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)
|
|
|
|
```elixir
|
|
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
|
|
```
|