- 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
226 lines
6.3 KiB
Markdown
226 lines
6.3 KiB
Markdown
# LiveView Test Template
|
|
|
|
Copy-pasteable template for testing a LiveView page following TowerOps conventions.
|
|
|
|
Replace `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
|
|
Replace `__resource__` with snake_case (e.g., `schedule`).
|
|
Replace `__resources__` with plural snake_case (e.g., `schedules`).
|
|
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
|
|
|
|
---
|
|
|
|
## `test/towerops_web/live/__resource___live_test.exs`
|
|
|
|
```elixir
|
|
defmodule ToweropsWeb.__RESOURCE__LiveTest do
|
|
use ToweropsWeb.ConnCase
|
|
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Towerops.__CONTEXT__
|
|
|
|
setup :register_and_log_in_user
|
|
|
|
setup %{conn: conn, user: user} do
|
|
{:ok, organization} =
|
|
Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
|
|
|
|
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
|
|
|
%{conn: conn, organization: organization}
|
|
end
|
|
|
|
describe "Index" do
|
|
test "requires authentication" do
|
|
conn = build_conn()
|
|
{:error, redirect} = live(conn, ~p"/__resources__")
|
|
|
|
assert {:redirect, %{to: path}} = redirect
|
|
assert path == ~p"/users/log-in"
|
|
end
|
|
|
|
test "renders page title and header", %{conn: conn} do
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
|
|
|
assert html =~ "__RESOURCES_TITLE__"
|
|
end
|
|
|
|
test "shows empty state when no data exists", %{conn: conn} do
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
|
|
|
assert html =~ "No __resources__ found"
|
|
end
|
|
|
|
test "lists existing records", %{conn: conn, organization: org} do
|
|
{:ok, _} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Test Item",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__")
|
|
|
|
assert html =~ "Test Item"
|
|
end
|
|
|
|
test "filters by status via URL params", %{conn: conn, organization: org} do
|
|
{:ok, _} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Active Item",
|
|
organization_id: org.id,
|
|
enabled: true
|
|
})
|
|
|
|
{:ok, _} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Archived Item",
|
|
organization_id: org.id,
|
|
enabled: false
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__?filter=active")
|
|
|
|
html = render(view)
|
|
assert html =~ "Active Item"
|
|
refute html =~ "Archived Item"
|
|
end
|
|
|
|
test "navigates to new form", %{conn: conn} do
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__")
|
|
|
|
assert view
|
|
|> element("a", "New")
|
|
|> render_click()
|
|
|> follow_redirect(conn, ~p"/__resources__/new")
|
|
end
|
|
|
|
test "deletes a record", %{conn: conn, organization: org} do
|
|
{:ok, item} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "To Delete",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, view, html} = live(conn, ~p"/__resources__")
|
|
assert html =~ "To Delete"
|
|
|
|
view
|
|
|> element("button[phx-click='delete'][phx-value-id='#{item.id}']")
|
|
|> render_click()
|
|
|
|
html = render(view)
|
|
refute html =~ "To Delete"
|
|
end
|
|
end
|
|
|
|
describe "Form - New" do
|
|
test "renders new form", %{conn: conn} do
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__/new")
|
|
|
|
assert html =~ "New __RESOURCE_TITLE__"
|
|
assert html =~ "Save"
|
|
end
|
|
|
|
test "validates form on change", %{conn: conn} do
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
|
|
|
|
html =
|
|
view
|
|
|> form("#__resource__-form", __resource__: %{name: ""})
|
|
|> render_change()
|
|
|
|
assert html =~ "can't be blank"
|
|
end
|
|
|
|
test "creates record on valid submit", %{conn: conn} do
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
|
|
|
|
{:ok, _view, html} =
|
|
view
|
|
|> form("#__resource__-form", __resource__: %{name: "New Item"})
|
|
|> render_submit()
|
|
|> follow_redirect(conn)
|
|
|
|
assert html =~ "__RESOURCE_TITLE__ created"
|
|
assert html =~ "New Item"
|
|
end
|
|
end
|
|
|
|
describe "Form - Edit" do
|
|
test "renders edit form with existing data", %{conn: conn, organization: org} do
|
|
{:ok, item} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Existing Item",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
|
|
|
assert html =~ "Edit __RESOURCE_TITLE__"
|
|
assert html =~ "Existing Item"
|
|
end
|
|
|
|
test "updates record on valid submit", %{conn: conn, organization: org} do
|
|
{:ok, item} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Original",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
|
|
|
{:ok, _view, html} =
|
|
view
|
|
|> form("#__resource__-form", __resource__: %{name: "Updated"})
|
|
|> render_submit()
|
|
|> follow_redirect(conn)
|
|
|
|
assert html =~ "__RESOURCE_TITLE__ updated"
|
|
assert html =~ "Updated"
|
|
end
|
|
|
|
test "shows delete button in edit mode", %{conn: conn, organization: org} do
|
|
{:ok, item} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Delete Me",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
|
|
|
assert html =~ "Delete"
|
|
end
|
|
|
|
test "deletes record from edit form", %{conn: conn, organization: org} do
|
|
{:ok, item} =
|
|
__CONTEXT__.create___resource__(%{
|
|
name: "Delete Me",
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
|
|
|
|
{:ok, _view, html} =
|
|
view
|
|
|> element("button[phx-click='delete']")
|
|
|> render_click()
|
|
|> follow_redirect(conn, ~p"/__resources__")
|
|
|
|
assert html =~ "__RESOURCE_TITLE__ deleted"
|
|
refute html =~ "Delete Me"
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- **`register_and_log_in_user`** is a shared setup helper defined in `ConnCase` — it creates a user and logs them in.
|
|
- **`Plug.Conn.put_session(:current_organization_id)`** sets the org context for the session.
|
|
- Use **`live/2`** to mount a LiveView, returns `{:ok, view, html}`.
|
|
- Use **`render_click/1`**, **`render_change/1`**, **`render_submit/1`** for events.
|
|
- Use **`element/2,3`** to target specific DOM elements.
|
|
- Use **`follow_redirect/2`** after `push_navigate` actions.
|
|
- Use **`render/1`** to get the current HTML after an event.
|