diff --git a/.github/workflows/incident-room.yml b/.github/workflows/incident-room.yml new file mode 100644 index 00000000..f6c115a8 --- /dev/null +++ b/.github/workflows/incident-room.yml @@ -0,0 +1,27 @@ +name: Incident room +on: + push: + branches: [main] + paths: ['applications/incident-room/**', '.github/workflows/incident-room.yml'] + pull_request: + paths: ['applications/incident-room/**', '.github/workflows/incident-room.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + checks: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: applications/incident-room + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 + with: + otp-version: '28.5.0.7' + elixir-version: '1.20.4' + - run: mix deps.get --check-locked + - run: mix format --check-formatted + - run: mix compile --warnings-as-errors + - run: mix assets.build + - run: mix test diff --git a/applications/incident-room/.env.example b/applications/incident-room/.env.example new file mode 100644 index 00000000..0d16fbfb --- /dev/null +++ b/applications/incident-room/.env.example @@ -0,0 +1,13 @@ +PGHOST=your-service-hostname +PGPORT=5432 +PGDATABASE=postgres +PGUSER=incident_app +PGPASSWORD=replace-with-runtime-password +PGSSLMODE=verify-full +PGSSLROOTCERT=/absolute/path/cloud-ca.pem +SECRET_KEY_BASE=replace-with-at-least-64-random-bytes-and-keep-stable-across-restarts +APP_ORIGIN=http://127.0.0.1:4000 +PORT=4000 +# Only seed command needs these; keep them out of the API environment. +SEED_CASEY_PASSWORD=replace-with-a-strong-password-at-least-16-bytes +SEED_MORGAN_PASSWORD=replace-with-a-different-strong-password diff --git a/applications/incident-room/.formatter.exs b/applications/incident-room/.formatter.exs new file mode 100644 index 00000000..40bbbc03 --- /dev/null +++ b/applications/incident-room/.formatter.exs @@ -0,0 +1 @@ +[import_deps: [:ecto, :ecto_sql, :phoenix_live_view], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["mix.exs", "{config,lib,test,priv,scripts}/**/*.{ex,exs,heex}"]] diff --git a/applications/incident-room/.gitignore b/applications/incident-room/.gitignore new file mode 100644 index 00000000..512dc27b --- /dev/null +++ b/applications/incident-room/.gitignore @@ -0,0 +1,6 @@ +/_build/ +/deps/ +/priv/static/assets/ +.env +*.pem +/erl_crash.dump diff --git a/applications/incident-room/.tool-versions b/applications/incident-room/.tool-versions new file mode 100644 index 00000000..fb2a1623 --- /dev/null +++ b/applications/incident-room/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.5.0.7 +elixir 1.20.4-otp-28 diff --git a/applications/incident-room/README.md b/applications/incident-room/README.md new file mode 100644 index 00000000..69cb4946 --- /dev/null +++ b/applications/incident-room/README.md @@ -0,0 +1,149 @@ +# Incident Room + +A small Phoenix LiveView application backed by ClickHouse Managed Postgres. A trusted team signs in, opens an incident, appends permanent notes and moves its status from investigating to monitoring to resolved. Monitoring can return to investigating; resolved incidents accept no further notes or transitions. + +The status row and its timeline entry commit in one Ecto transaction. Browsers send an expected version; the row lock serializes writers and a stale version is rejected. Phoenix PubSub runs **after commit**. It is an ephemeral notification: reconnecting browsers load authoritative database rows rather than replay broadcasts. + +This is one shared team, not a tenant isolation example. All authenticated accounts can read and update every incident. Author IDs come from the verified session, never a browser field. The newest 100 incidents and latest 200 entries per room are displayed, with stable timestamp/UUID ordering; older entries remain stored. There is no pagination or historical export UI. + +## Versions and layout + +Pinned and tested: Elixir 1.20.4 / Erlang OTP 28.5.0.7, Phoenix 1.8.15, LiveView 1.2.12, Ecto 3.14.2 / Ecto SQL 3.14.0, Postgrex 0.22.4, Bandit 1.12.5 and esbuild 0.28.2. Exact direct dependencies are in `mix.exs`, transitive dependencies in `mix.lock`, runtime versions in `.tool-versions`. + +- `lib/incident_room/incidents.ex`: transactions, validation, locking and post-commit notifications. +- `lib/incident_room/auth.ex`: BCrypt passwords and expiring, hashed database sessions. +- `lib/incident_room_web`: authenticated HTTP routes and LiveView mounts/events. +- `priv/repo/migrations`: Ecto migration; `scripts/migrate.exs` is the separate owner-only entry point. +- `sql`: administrator role bootstrap, owner grants and optional dedicated-fixture cleanup. +- `test`: independent domain checks and opt-in real Cloud checks. + +Install the pinned Elixir/OTP pair with your preferred version manager. All commands below run from this directory. Use a native Linux development environment if following the recorded acceptance setup. PostgreSQL client tools are needed for the visible role steps. + +## Create a Cloud service + +Authenticate `clickhousectl` using its supported private environment configuration. A service incurs charges until deleted. This example needs no analytical ClickHouse service and no high availability configuration. The verified small shape is `c6gd.large` in AWS `us-east-1`; consult current Cloud availability before choosing another shape. + +```sh +mkdir -m 700 -p /tmp/incident-private +export ORG_ID=your-cloud-organization-id +clickhousectl cloud postgres create --org-id "$ORG_ID" --name incident-room-demo \ + --provider aws --region us-east-1 --size c6gd.large \ + --pg-version 18 --ha-type none --json > /tmp/incident-private/create.json +# Record the returned id as SERVICE_ID; keep the full receipt private. +clickhousectl cloud postgres get "$SERVICE_ID" --org-id "$ORG_ID" --json +# Continue only when the state is running. +clickhousectl cloud postgres certs get "$SERVICE_ID" --org-id "$ORG_ID" --output /tmp/incident-private/cloud-ca.pem +``` + +The create receipt contains the initial administrator password; later `get` calls do not return it. Set the returned hostname, port and database privately. Copy `.env.example` outside the repository, replace all placeholders and use an absolute CA path. Do not commit receipts, environment files, passwords or private endpoints. See the [Managed Postgres Phoenix guide](https://clickhouse.com/docs/products/managed-postgres/guides/phoenix). + +`PGSSLMODE=verify-full` protects `psql`. The application explicitly supplies OTP `verify_peer`, the downloaded `cacertfile`, hostname SNI, and `pkix_verify_hostname_match_fun(:https)` through Postgrex. Setting `ssl: true` alone would not establish this verification claim. + +## Bootstrap, migrate and seed + +Generate different strong passwords for the migration and runtime roles. The administrator owns neither application tables nor the server process. Preserve a random `SECRET_KEY_BASE` of at least 64 bytes across restarts. Example generation inside your development environment: + +```sh +openssl rand -base64 64 +``` + +Load your private environment file with shell export enabled so the hostname, CA and origin reach Mix: + +```sh +set -a +source /private/path/setup.env +set +a +``` + +Set `ADMIN_USER`, `ADMIN_PASSWORD`, `MIGRATION_PASSWORD` and `APP_PASSWORD` privately, then execute the following in order: + +```sh +export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD" +psql -X -v migration_password="$MIGRATION_PASSWORD" \ + -v app_password="$APP_PASSWORD" -f sql/bootstrap.sql + +export PGUSER=incident_migration PGPASSWORD="$MIGRATION_PASSWORD" +mix deps.get +mix run scripts/migrate.exs +# Repeating migrations is safe and reports that they are already applied. +mix run scripts/migrate.exs + +# Supply these only for seeding; each must contain at least 16 bytes. +export SEED_CASEY_PASSWORD='replace-with-a-private-strong-password' +export SEED_MORGAN_PASSWORD='replace-with-a-different-private-strong-password' +mix run priv/repo/seeds.exs +mix run priv/repo/seeds.exs +psql -X -f sql/grants.sql +``` + +The bootstrap creates an owned `incident_room` schema. Ecto creates its history table there; no database `CREATE` privilege is required. The explicit migration script uses conventional `Ecto.Migrator.run` in the owner process; the HTTP server never migrates at startup. Seed reruns retain existing passwords/data. Seeded accounts are `casey@example.test` and `morgan@example.test`, with the supplied passwords. There is no public registration or password reset flow. + +The runtime role can select users; select/insert/delete sessions; select/insert/update incidents; and select/insert entries. It cannot alter users, edit/delete timeline rows, read migration history or create tables. Cooperative row locking and state/version checks belong to the application; the shared trusted runtime role could bypass those rules if used outside this code. The migration owner can still administer all tables. + +## Run and use the room + +```sh +export PGUSER=incident_app PGPASSWORD="$APP_PASSWORD" +unset ADMIN_USER ADMIN_PASSWORD MIGRATION_PASSWORD SEED_CASEY_PASSWORD SEED_MORGAN_PASSWORD +mix assets.build +mix phx.server +``` + +Open `http://127.0.0.1:4000`, sign in, open an incident and add a note. Sign in as the second account in another browser to see committed changes. The default listener is loopback. `APP_ORIGIN` must be a valid HTTP(S) origin and controls Phoenix's origin allowlist. If deploying, configure a deliberate proxy/listener and HTTPS origin. Secure cookies are enabled for an HTTPS origin; HTTP loopback development uses HttpOnly, SameSite=Lax encrypted/signed cookies. HTTP CSRF protection and WebSocket origin checks remain enabled. + +Sessions contain a random token whose SHA-256 hash and eight-hour expiry live in Postgres. Every LiveView mount, write operation and received update validates the session. Sign-out removes the session and disconnects its sockets. Keeping `SECRET_KEY_BASE` stable lets surviving sessions work after a process restart. Seed/admin credentials are not needed by the server. + +Titles allow 1–160 Unicode codepoints and notes 1–2,000 codepoints before trimming, reject whitespace-only input and embedded NUL. This matches PostgreSQL's character count; browser `maxlength` remains a convenience rather than the authority. HTTP bodies are capped at 16 KiB. Successful note submission clears its form; incoming updates preserve another responder's unsaved draft. + +## Validate + +```sh +mix format --check-formatted +mix compile --warnings-as-errors +mix assets.build +mix test +``` + +The default suite runs three domain checks without a database. CI uses only these checks and a build; it has no Cloud credentials. For the dedicated seeded Cloud fixture, set the runtime connection variables and additionally supply `MIGRATION_PASSWORD` to the **test process only**: + +```sh +CLOUD_TEST=true mix test --include cloud +``` + +Cloud tests add rows to the dedicated fixture. They check TLS positive/negative controls, restricted permissions, concurrent expected-version updates, author/session authority, bounded Unicode/timeline behavior and actual blocked sessions. A temporary owner-only trigger, scoped to one test incident, forces the timeline insert to fail after the status update; the test verifies rollback and no broadcast, then removes the trigger. Never run this fault-injection suite against production. + +For actual two-browser delivery/reconnect/restart checks, install Playwright in an isolated native environment and provide the seed passwords to the harness: + +```sh +python3 -m venv /tmp/incident-browser-venv +/tmp/incident-browser-venv/bin/pip install playwright==1.58.0 +/tmp/incident-browser-venv/bin/playwright install --with-deps chromium +export SEED_CASEY_PASSWORD='your-seeded-password' +export SEED_MORGAN_PASSWORD='your-other-seeded-password' +/tmp/incident-browser-venv/bin/python scripts/browser_acceptance.py +``` + +The harness starts an HTTP process containing only runtime credentials, uses separate Chromium contexts, counts actual received WebSocket frames, deliberately misses a broadcast, reconnects, and replaces the server process. A screenshot and private server log go to `EVIDENCE_DIR` (default `/tmp/incident-browser-evidence`). Keep logs outside Git. + +## Cleanup and limits + +Stop the application before cleanup. On a dedicated fixture only, the administrator can remove this schema and its roles after restoring the administrator credentials from your private setup file: + +```sh +set -a +source /private/path/setup.env +set +a +export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD" +psql -X -f sql/cleanup.sql +``` + + This is destructive and not part of normal startup. It was used to verify clean bootstrap followed by repeated migration/seeding. Delete your own Cloud service when finished and verify its absence: + +```sh +clickhousectl cloud postgres delete "$SERVICE_ID" --org-id "$ORG_ID" +clickhousectl cloud postgres list --org-id "$ORG_ID" --json +``` + +PubSub is local process coordination, not a durable queue or distributed-delivery guarantee. There is no monitoring ingestion, incident integration, email, escalation, tenant policy, account administration, expiry sweep, timeline pagination or performance claim. Provisioning here uses no HA. A larger deployment needs deliberate operational and authorization choices. + +Primary references: [LiveView security model](https://phoenix-live-view.hexdocs.pm/security-model.html), [Ecto transactions](https://ecto.hexdocs.pm/Ecto.Repo.html#c:transact/2), [Ecto migrations](https://ecto-sql.hexdocs.pm/Ecto.Migration.html), [Postgrex connection options](https://postgrex.hexdocs.pm/Postgrex.html), [OTP 28 TLS options](https://www.erlang.org/docs/28/apps/ssl/ssl.html). diff --git a/applications/incident-room/assets/js/app.js b/applications/incident-room/assets/js/app.js new file mode 100644 index 00000000..25db5c21 --- /dev/null +++ b/applications/incident-room/assets/js/app.js @@ -0,0 +1,6 @@ +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/applications/incident-room/config/config.exs b/applications/incident-room/config/config.exs new file mode 100644 index 00000000..cd33f97d --- /dev/null +++ b/applications/incident-room/config/config.exs @@ -0,0 +1,22 @@ +import Config +config :incident_room, ecto_repos: [IncidentRoom.Repo] +config :incident_room, IncidentRoom.Repo, migration_default_prefix: "incident_room" + +config :incident_room, IncidentRoomWeb.Endpoint, + url: [host: "127.0.0.1", port: 4000], + adapter: Bandit.PhoenixAdapter, + render_errors: [formats: [html: IncidentRoomWeb.ErrorHTML], layout: false], + pubsub_server: IncidentRoom.PubSub, + live_view: [signing_salt: "live-room-signing"] + +config :phoenix, :json_library, Jason + +config :esbuild, + version: "0.28.2", + default: [ + args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +config :logger, level: :info diff --git a/applications/incident-room/config/runtime.exs b/applications/incident-room/config/runtime.exs new file mode 100644 index 00000000..ca7db0c0 --- /dev/null +++ b/applications/incident-room/config/runtime.exs @@ -0,0 +1,49 @@ +import Config +cloud_test = System.get_env("CLOUD_TEST") == "true" +config :incident_room, :start_repo, config_env() != :test or cloud_test + +if (config_env() != :test or cloud_test) and is_nil(System.get_env("PGHOST")), + do: raise("PGHOST is required; configure the verified Cloud endpoint") + +if host = System.get_env("PGHOST") do + config :incident_room, IncidentRoom.Repo, + hostname: host, + port: String.to_integer(System.get_env("PGPORT", "5432")), + database: System.get_env("PGDATABASE", "postgres"), + username: System.fetch_env!("PGUSER"), + password: System.fetch_env!("PGPASSWORD"), + pool_size: 5, + queue_target: 5_000, + queue_interval: 5_000, + ssl: [ + verify: :verify_peer, + cacertfile: String.to_charlist(System.fetch_env!("PGSSLROOTCERT")), + server_name_indication: String.to_charlist(host), + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ], + timeout: 30_000, + connect_timeout: 15_000 +end + +origin = System.get_env("APP_ORIGIN", "http://127.0.0.1:4000") +uri = URI.parse(origin) + +unless uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" and + uri.userinfo == nil and uri.path in [nil, ""] and uri.query == nil and + uri.fragment == nil, + do: raise("APP_ORIGIN must be an http(s) origin") + +secret = + if config_env() == :test, + do: System.get_env("SECRET_KEY_BASE", String.duplicate("test-only-", 8)), + else: System.fetch_env!("SECRET_KEY_BASE") + +if byte_size(secret) < 64, do: raise("SECRET_KEY_BASE must have at least 64 bytes") +config :incident_room, :cookie_secure, uri.scheme == "https" + +config :incident_room, IncidentRoomWeb.Endpoint, + secret_key_base: secret, + server: System.get_env("PHX_SERVER") == "true", + url: [host: uri.host, port: uri.port, scheme: uri.scheme], + check_origin: [origin], + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT", "4000"))] diff --git a/applications/incident-room/lib/incident_room/application.ex b/applications/incident-room/lib/incident_room/application.ex new file mode 100644 index 00000000..6499af16 --- /dev/null +++ b/applications/incident-room/lib/incident_room/application.ex @@ -0,0 +1,12 @@ +defmodule IncidentRoom.Application do + use Application + + def start(_type, _args) do + repo = if Application.get_env(:incident_room, :start_repo), do: [IncidentRoom.Repo], else: [] + children = repo ++ [{Phoenix.PubSub, name: IncidentRoom.PubSub}, IncidentRoomWeb.Endpoint] + Supervisor.start_link(children, strategy: :one_for_one, name: IncidentRoom.Supervisor) + end + + def config_change(changed, removed, _extra), + do: IncidentRoomWeb.Endpoint.config_change(changed, removed) +end diff --git a/applications/incident-room/lib/incident_room/auth.ex b/applications/incident-room/lib/incident_room/auth.ex new file mode 100644 index 00000000..f25f02a6 --- /dev/null +++ b/applications/incident-room/lib/incident_room/auth.ex @@ -0,0 +1,57 @@ +defmodule IncidentRoom.Auth do + import Ecto.Query + alias IncidentRoom.{Repo, User, Session} + @ttl_seconds 8 * 60 * 60 + + def authenticate(email, password) + when is_binary(email) and is_binary(password) and byte_size(email) <= 254 and + byte_size(password) <= 128 do + user = Repo.get_by(User, email: email |> String.trim() |> String.downcase()) + + cond do + user && Bcrypt.verify_pass(password, user.password_hash) -> + {:ok, user} + + user -> + {:error, :invalid_credentials} + + true -> + Bcrypt.no_user_verify() + {:error, :invalid_credentials} + end + end + + def authenticate(_, _), do: {:error, :invalid_credentials} + + def create_session(user) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + + Repo.insert!(%Session{ + token_hash: hash(token), + user_id: user.id, + expires_at: DateTime.add(DateTime.utc_now(), @ttl_seconds, :second) + }) + + token + end + + def user(token) when is_binary(token) and byte_size(token) <= 128 do + Repo.one( + from s in Session, + join: u in User, + on: u.id == s.user_id, + where: s.token_hash == ^hash(token) and s.expires_at > ^DateTime.utc_now(), + select: u + ) + end + + def user(_), do: nil + + def delete_session(token) do + Repo.delete_all(from s in Session, where: s.token_hash == ^hash(token)) + IncidentRoomWeb.Endpoint.broadcast(socket_id(token), "disconnect", %{}) + end + + def socket_id(token), do: "session:" <> Base.url_encode64(hash(token), padding: false) + defp hash(token), do: :crypto.hash(:sha256, token) +end diff --git a/applications/incident-room/lib/incident_room/incidents.ex b/applications/incident-room/lib/incident_room/incidents.ex new file mode 100644 index 00000000..00dcb943 --- /dev/null +++ b/applications/incident-room/lib/incident_room/incidents.ex @@ -0,0 +1,147 @@ +defmodule IncidentRoom.Incidents do + import Ecto.Query + alias IncidentRoom.{Repo, Auth, Incident, Entry} + + @transitions %{ + "investigating" => ["monitoring"], + "monitoring" => ["investigating", "resolved"], + "resolved" => [] + } + + def valid_transition?(from, to), do: to in Map.get(@transitions, from, []) + + def valid_text?(text, limit), + do: + is_binary(text) and String.trim(text) != "" and length(String.to_charlist(text)) <= limit and + not String.contains?(text, <<0>>) + + def list, + do: Repo.all(from i in Incident, order_by: [desc: i.inserted_at, desc: i.id], limit: 100) + + def get(id), do: Repo.get(Incident, id) + + def timeline(id), + do: + Repo.all( + from e in Entry, + where: e.incident_id == ^id, + order_by: [desc: e.inserted_at, desc: e.id], + limit: 200, + preload: :author + ) + |> Enum.reverse() + + def open(token, attrs) when is_map(attrs) do + result = + Repo.transact(fn -> + with %{} = user <- Auth.user(token), + true <- Map.keys(attrs) == ["title"], + true <- valid_text?(attrs["title"], 160), + {:ok, incident} <- + Repo.insert(Ecto.Changeset.change(%Incident{}, title: String.trim(attrs["title"]))), + {:ok, _entry} <- entry(incident, user, "opened", "Incident opened") do + {:ok, incident} + else + nil -> {:error, :unauthenticated} + false -> {:error, :invalid_input} + {:error, reason} -> {:error, reason} + end + end) + + broadcast(result) + end + + def open(_, _), do: {:error, :invalid_input} + + def note(token, id, attrs) when is_map(attrs) do + result = + Repo.transact(fn -> + with %{} = user <- Auth.user(token), + true <- Map.keys(attrs) == ["body"], + true <- valid_text?(attrs["body"], 2000), + %{} = incident <- locked(id), + false <- incident.status == "resolved", + {:ok, _entry} <- entry(incident, user, "note", String.trim(attrs["body"])) do + {:ok, incident} + else + nil -> {:error, :not_found_or_unauthenticated} + true -> {:error, :resolved} + false -> {:error, :invalid_input} + {:error, reason} -> {:error, reason} + end + end) + + broadcast(result) + end + + def note(_, _, _), do: {:error, :invalid_input} + + def transition(token, id, attrs) when is_map(attrs) do + result = + Repo.transact(fn -> + with %{} = user <- Auth.user(token), + true <- Enum.sort(Map.keys(attrs)) == ["status", "version"], + %{} = incident <- locked(id), + {:ok, version} <- version(attrs["version"]), + :ok <- check_version(incident, version), + true <- valid_transition?(incident.status, attrs["status"]), + {:ok, changed} <- + Repo.update( + Ecto.Changeset.change(incident, + status: attrs["status"], + version: incident.version + 1 + ) + ), + {:ok, _entry} <- + entry( + changed, + user, + "status", + "Status changed from #{incident.status} to #{changed.status}" + ) do + {:ok, changed} + else + nil -> {:error, :not_found_or_unauthenticated} + false -> {:error, :invalid_transition_or_input} + {:error, reason} -> {:error, reason} + end + end) + + broadcast(result) + end + + def transition(_, _, _), do: {:error, :invalid_input} + + defp locked(id), do: Repo.one(from i in Incident, where: i.id == ^id, lock: "FOR UPDATE") + defp version(n) when is_integer(n) and n >= 0, do: {:ok, n} + + defp version(n) when is_binary(n) do + case Integer.parse(n) do + {version, ""} when version >= 0 -> {:ok, version} + _ -> {:error, :invalid_version} + end + end + + defp version(_), do: {:error, :invalid_version} + defp check_version(%{version: version}, version), do: :ok + defp check_version(_, _), do: {:error, :stale_version} + + defp entry(incident, user, kind, body), + do: + Repo.insert( + Ecto.Changeset.change(%Entry{}, + incident_id: incident.id, + author_id: user.id, + kind: kind, + body: body + ) + ) + + defp broadcast({:ok, incident} = result) do + Phoenix.PubSub.broadcast(IncidentRoom.PubSub, "incident:#{incident.id}", :committed) + Phoenix.PubSub.broadcast(IncidentRoom.PubSub, "incidents", :committed) + result + end + + defp broadcast(error), do: error +end diff --git a/applications/incident-room/lib/incident_room/models.ex b/applications/incident-room/lib/incident_room/models.ex new file mode 100644 index 00000000..b4e486fb --- /dev/null +++ b/applications/incident-room/lib/incident_room/models.ex @@ -0,0 +1,45 @@ +defmodule IncidentRoom.User do + use Ecto.Schema + @schema_prefix "incident_room" + @primary_key {:id, :binary_id, autogenerate: true} + schema "users" do + field :email, :string + field :name, :string + field :password_hash, :string, redact: true + end +end + +defmodule IncidentRoom.Session do + use Ecto.Schema + @schema_prefix "incident_room" + @primary_key {:token_hash, :binary, autogenerate: false} + schema "sessions" do + belongs_to :user, IncidentRoom.User, type: :binary_id + field :expires_at, :utc_datetime_usec + end +end + +defmodule IncidentRoom.Incident do + use Ecto.Schema + @schema_prefix "incident_room" + @primary_key {:id, :binary_id, autogenerate: true} + schema "incidents" do + field :title, :string + field :status, :string, default: "investigating" + field :version, :integer, default: 0 + timestamps(type: :utc_datetime_usec) + end +end + +defmodule IncidentRoom.Entry do + use Ecto.Schema + @schema_prefix "incident_room" + @primary_key {:id, :binary_id, autogenerate: true} + schema "entries" do + belongs_to :incident, IncidentRoom.Incident, type: :binary_id + belongs_to :author, IncidentRoom.User, type: :binary_id + field :kind, :string + field :body, :string + timestamps(type: :utc_datetime_usec, updated_at: false) + end +end diff --git a/applications/incident-room/lib/incident_room/repo.ex b/applications/incident-room/lib/incident_room/repo.ex new file mode 100644 index 00000000..c7c59437 --- /dev/null +++ b/applications/incident-room/lib/incident_room/repo.ex @@ -0,0 +1,3 @@ +defmodule IncidentRoom.Repo do + use Ecto.Repo, otp_app: :incident_room, adapter: Ecto.Adapters.Postgres +end diff --git a/applications/incident-room/lib/incident_room_web/auth.ex b/applications/incident-room/lib/incident_room_web/auth.ex new file mode 100644 index 00000000..c1c60306 --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/auth.ex @@ -0,0 +1,24 @@ +defmodule IncidentRoomWeb.Auth do + import Plug.Conn + alias IncidentRoom.Auth + + def fetch_user(conn, _) do + Plug.Conn.assign(conn, :current_user, Auth.user(get_session(conn, :user_token))) + end + + def require_user(%{assigns: %{current_user: nil}} = conn, _), + do: conn |> Phoenix.Controller.redirect(to: "/sign-in") |> halt() + + def require_user(conn, _), do: conn + + def on_mount(:required, _params, session, socket) do + token = session["user_token"] + + case Auth.user(token) do + nil -> {:halt, Phoenix.LiveView.redirect(socket, to: "/sign-in")} + user -> {:cont, Phoenix.Component.assign(socket, current_user: user, session_token: token)} + end + end + + def active?(socket), do: Auth.user(socket.assigns.session_token) != nil +end diff --git a/applications/incident-room/lib/incident_room_web/endpoint.ex b/applications/incident-room/lib/incident_room_web/endpoint.ex new file mode 100644 index 00000000..b6c211d1 --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/endpoint.ex @@ -0,0 +1,44 @@ +defmodule IncidentRoomWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :incident_room + + @session_options [ + store: :cookie, + key: "_incident_room", + signing_salt: "session-signing", + encryption_salt: "session-encryption", + same_site: "Lax", + max_age: 8 * 60 * 60, + http_only: true + ] + socket("/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: false + ) + + plug(Plug.Static, at: "/", from: :incident_room, gzip: false, only: ~w(assets app.css)) + plug(Plug.RequestId) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library(), + length: 16_384 + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(:session) + plug(IncidentRoomWeb.Router) + + defp session(conn, _) do + options = + Keyword.put( + @session_options, + :secure, + Application.get_env(:incident_room, :cookie_secure, false) + ) + + Plug.Session.call(conn, Plug.Session.init(options)) + end +end diff --git a/applications/incident-room/lib/incident_room_web/index_live.ex b/applications/incident-room/lib/incident_room_web/index_live.ex new file mode 100644 index 00000000..90edf6da --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/index_live.ex @@ -0,0 +1,69 @@ +defmodule IncidentRoomWeb.IndexLive do + use Phoenix.LiveView, layout: false + alias IncidentRoom.{Incidents, Auth} + + def mount(_, _, socket) do + if connected?(socket), do: Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incidents") + + {:ok, + assign(socket, incidents: Incidents.list(), form: to_form(%{"title" => ""}, as: :incident))} + end + + def handle_event("open", %{"incident" => attrs}, socket) do + case Incidents.open(socket.assigns.session_token, attrs) do + {:ok, incident} -> + {:noreply, push_navigate(socket, to: "/incidents/#{incident.id}")} + + {:error, _} -> + {:noreply, + put_flash( + socket, + :error, + "Enter a title up to 160 characters; use only the title field." + )} + end + end + + def handle_event(_, _, socket), + do: {:noreply, put_flash(socket, :error, "Invalid update fields.")} + + def handle_info(:committed, socket) do + if Auth.user(socket.assigns.session_token), + do: {:noreply, assign(socket, incidents: Incidents.list())}, + else: {:noreply, redirect(socket, to: "/sign-in")} + end + + def render(assigns) do + ~H""" +
+
+ Incident Room +
+
+ TEAM INCIDENTS

Keep the response in one room.

+ Open an incident, add what you know, and follow the team's progress. +

+
+ + <.form for={@form} id="open-incident" phx-submit="open" class="open-form"> + + +
+
+ incident.status}>{String.capitalize(incident.status)}

+ {incident.title} +

+
View timeline →

+ No incidents yet. Open the first room above. +

+
+ +
+ """ + end +end diff --git a/applications/incident-room/lib/incident_room_web/layouts.ex b/applications/incident-room/lib/incident_room_web/layouts.ex new file mode 100644 index 00000000..db75e032 --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/layouts.ex @@ -0,0 +1,25 @@ +defmodule IncidentRoomWeb.Layouts do + use Phoenix.Component + + def root(assigns) do + ~H""" + + + + + + + Incident Room + + + + {@inner_content} + + """ + end +end + +defmodule IncidentRoomWeb.ErrorHTML do + def render(template, _assigns), do: Phoenix.Controller.status_message_from_template(template) +end diff --git a/applications/incident-room/lib/incident_room_web/room_live.ex b/applications/incident-room/lib/incident_room_web/room_live.ex new file mode 100644 index 00000000..c453520f --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/room_live.ex @@ -0,0 +1,167 @@ +defmodule IncidentRoomWeb.RoomLive do + use Phoenix.LiveView, layout: false + alias IncidentRoom.Incidents + alias IncidentRoomWeb.Auth + + def mount(%{"id" => id}, _, socket) do + case Ecto.UUID.cast(id) do + {:ok, id} -> + if connected?(socket), do: Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{id}") + + case Incidents.get(id) do + nil -> + {:ok, redirect(socket, to: "/")} + + incident -> + {:ok, + socket + |> assign( + incident: incident, + timeline: Incidents.timeline(id), + connected: connected?(socket), + form: to_form(%{"body" => ""}, as: :note) + )} + end + + :error -> + {:ok, redirect(socket, to: "/")} + end + end + + def handle_event("note", %{"note" => attrs}, socket), + do: + event( + socket, + fn -> Incidents.note(socket.assigns.session_token, socket.assigns.incident.id, attrs) end, + true + ) + + def handle_event("transition", attrs, socket), + do: + event(socket, fn -> + Incidents.transition(socket.assigns.session_token, socket.assigns.incident.id, attrs) + end) + + def handle_event("draft", %{"note" => attrs}, socket) when is_map(attrs), + do: {:noreply, assign(socket, form: to_form(Map.take(attrs, ["body"]), as: :note))} + + def handle_event(_, _, socket), + do: {:noreply, put_flash(socket, :error, "Invalid update fields.")} + + defp event(socket, callback, clear_note \\ false) do + if Auth.active?(socket) do + case callback.() do + {:ok, _} -> + {:noreply, + socket |> put_flash(:info, "Update saved.") |> reload() |> clear_form(clear_note)} + + {:error, :stale_version} -> + {:noreply, + socket + |> reload() + |> put_flash( + :error, + "The status changed. Review the current state before trying again." + )} + + {:error, _} -> + {:noreply, + put_flash( + socket, + :error, + "This update was not saved. Check the fields and current status." + )} + end + else + {:noreply, redirect(socket, to: "/sign-in")} + end + end + + defp clear_form(socket, true), do: assign(socket, form: to_form(%{"body" => ""}, as: :note)) + defp clear_form(socket, false), do: socket + + def handle_info(:committed, socket) do + if Auth.active?(socket), + do: {:noreply, reload(socket)}, + else: {:noreply, redirect(socket, to: "/sign-in")} + end + + defp reload(socket), + do: + assign(socket, + incident: Incidents.get(socket.assigns.incident.id), + timeline: Incidents.timeline(socket.assigns.incident.id) + ) + + def render(assigns) do + ~H""" +
+
+ ← All incidents +
+
+
+ SHARED INCIDENT

{@incident.title}

@incident.status} + >{String.capitalize(@incident.status)}Version {@incident.version} +
{if @connected, do: "● Live room", else: "Connecting…"} +
+

+ {@flash["info"]} +

+
+
+

Timeline

+ Latest 200 entries, in chronological order. Earlier entries remain in the database. +

    +
  1. +

    {entry.body}

    +
  2. +
+
+ +
+
+ """ + end +end diff --git a/applications/incident-room/lib/incident_room_web/router.ex b/applications/incident-room/lib/incident_room_web/router.ex new file mode 100644 index 00000000..acb9c7ab --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/router.ex @@ -0,0 +1,37 @@ +defmodule IncidentRoomWeb.Router do + use Phoenix.Router + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + import IncidentRoomWeb.Auth + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, html: {IncidentRoomWeb.Layouts, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(:fetch_user) + end + + pipeline :authenticated do + plug(:require_user) + end + + scope "/", IncidentRoomWeb do + pipe_through(:browser) + get("/sign-in", SessionController, :new) + post("/sign-in", SessionController, :create) + post("/sign-out", SessionController, :delete) + end + + scope "/", IncidentRoomWeb do + pipe_through([:browser, :authenticated]) + + live_session :team, on_mount: [{IncidentRoomWeb.Auth, :required}] do + live("/", IndexLive) + live("/incidents/:id", RoomLive) + end + end +end diff --git a/applications/incident-room/lib/incident_room_web/session_controller.ex b/applications/incident-room/lib/incident_room_web/session_controller.ex new file mode 100644 index 00000000..dc01b759 --- /dev/null +++ b/applications/incident-room/lib/incident_room_web/session_controller.ex @@ -0,0 +1,63 @@ +defmodule IncidentRoomWeb.SessionController do + use Phoenix.Controller, formats: [:html] + import Plug.Conn + alias IncidentRoom.Auth + plug(:put_layout, false) + def new(conn, _), do: render(conn, :new, error: nil) + + def create(conn, %{"session" => %{"email" => email, "password" => password} = attrs}) do + if Enum.sort(Map.keys(attrs)) == ["email", "password"] do + case Auth.authenticate(email, password) do + {:ok, user} -> + token = Auth.create_session(user) + + conn + |> configure_session(renew: true) + |> clear_session() + |> put_session(:user_token, token) + |> put_session(:live_socket_id, Auth.socket_id(token)) + |> redirect(to: "/") + + _ -> + conn |> put_status(401) |> render(:new, error: "Check your email and password.") + end + else + conn |> put_status(400) |> render(:new, error: "Invalid sign-in fields.") + end + end + + def create(conn, _), + do: conn |> put_status(400) |> render(:new, error: "Enter your email and password.") + + def delete(conn, _) do + if token = get_session(conn, :user_token), do: Auth.delete_session(token) + conn |> clear_session() |> configure_session(drop: true) |> redirect(to: "/sign-in") + end +end + +defmodule IncidentRoomWeb.SessionHTML do + use Phoenix.Component + + def new(assigns) do + ~H""" +
+ INCIDENT ROOM +

A shared place to work through an incident.

+

Sign in with your team account to follow the timeline and add updates.

+ +
+ + + + +
+

Team accounts are seeded by the person running this example.

+
+ """ + end +end diff --git a/applications/incident-room/mix.exs b/applications/incident-room/mix.exs new file mode 100644 index 00000000..87549c27 --- /dev/null +++ b/applications/incident-room/mix.exs @@ -0,0 +1,36 @@ +defmodule IncidentRoom.MixProject do + use Mix.Project + + def project do + [ + app: :incident_room, + version: "0.1.0", + elixir: "~> 1.20", + elixirc_paths: ["lib"], + deps: deps(), + aliases: [ + "assets.build": ["esbuild default"], + "assets.deploy": ["esbuild default --minify"] + ] + ] + end + + def application, + do: [mod: {IncidentRoom.Application, []}, extra_applications: [:logger, :runtime_tools, :ssl]] + + defp deps do + [ + {:phoenix, "== 1.8.15"}, + {:phoenix_live_view, "== 1.2.12"}, + {:phoenix_html, "== 4.3.0"}, + {:ecto, "== 3.14.2"}, + {:ecto_sql, "== 3.14.0"}, + {:postgrex, "== 0.22.4"}, + {:bcrypt_elixir, "== 3.3.2"}, + {:bandit, "== 1.12.5"}, + {:jason, "== 1.4.5"}, + {:esbuild, "== 0.10.0", runtime: false}, + {:floki, "== 0.38.4", only: :test} + ] + end +end diff --git a/applications/incident-room/mix.lock b/applications/incident-room/mix.lock new file mode 100644 index 00000000..ee302b8b --- /dev/null +++ b/applications/incident-room/mix.lock @@ -0,0 +1,27 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, + "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} diff --git a/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs b/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs new file mode 100644 index 00000000..2ba63931 --- /dev/null +++ b/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs @@ -0,0 +1,51 @@ +defmodule IncidentRoom.Repo.Migrations.CreateRoom do + use Ecto.Migration + + def change do + create table(:users, primary_key: false) do + add :id, :uuid, primary_key: true + add :email, :string, null: false + add :name, :string, null: false + add :password_hash, :text, null: false + end + + create unique_index(:users, [:email]) + + create table(:sessions, primary_key: false) do + add :token_hash, :binary, primary_key: true + add :user_id, references(:users, type: :uuid), null: false + add :expires_at, :utc_datetime_usec, null: false + end + + create index(:sessions, [:expires_at]) + + create table(:incidents, primary_key: false) do + add :id, :uuid, primary_key: true + add :title, :string, size: 160, null: false + add :status, :string, null: false, default: "investigating" + add :version, :integer, null: false, default: 0 + timestamps(type: :utc_datetime_usec) + end + + create constraint(:incidents, :valid_status, + check: "status IN ('investigating', 'monitoring', 'resolved')" + ) + + create constraint(:incidents, :valid_version, check: "version >= 0") + create constraint(:incidents, :nonblank_title, check: "length(btrim(title)) > 0") + + create table(:entries, primary_key: false) do + add :id, :uuid, primary_key: true + add :incident_id, references(:incidents, type: :uuid), null: false + add :author_id, references(:users, type: :uuid), null: false + add :kind, :string, null: false + add :body, :text, null: false + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create constraint(:entries, :valid_entry_kind, check: "kind IN ('opened', 'note', 'status')") + create constraint(:entries, :bounded_body, check: "length(btrim(body)) BETWEEN 1 AND 2000") + create index(:entries, [:incident_id, :inserted_at, :id]) + create index(:incidents, [:inserted_at, :id]) + end +end diff --git a/applications/incident-room/priv/repo/seeds.exs b/applications/incident-room/priv/repo/seeds.exs new file mode 100644 index 00000000..965fecfb --- /dev/null +++ b/applications/incident-room/priv/repo/seeds.exs @@ -0,0 +1,33 @@ +alias IncidentRoom.{Repo, User, Incident, Entry} + +for {id, email, name, env} <- [ + {"00000000-0000-0000-0000-000000000001", "casey@example.test", "Casey", + "SEED_CASEY_PASSWORD"}, + {"00000000-0000-0000-0000-000000000002", "morgan@example.test", "Morgan", + "SEED_MORGAN_PASSWORD"} + ] do + password = System.fetch_env!(env) + if byte_size(password) < 16, do: raise("Seed passwords need at least 16 bytes") + + Repo.insert!( + %User{id: id, email: email, name: name, password_hash: Bcrypt.hash_pwd_salt(password)}, + on_conflict: :nothing + ) +end + +incident_id = "00000000-0000-0000-0000-000000000003" + +Repo.insert!(%Incident{id: incident_id, title: "Elevated API errors", status: "investigating"}, + on_conflict: :nothing +) + +Repo.insert!( + %Entry{ + id: "00000000-0000-0000-0000-000000000004", + incident_id: incident_id, + author_id: "00000000-0000-0000-0000-000000000001", + kind: "opened", + body: "Incident opened" + }, + on_conflict: :nothing +) diff --git a/applications/incident-room/priv/static/app.css b/applications/incident-room/priv/static/app.css new file mode 100644 index 00000000..48ebc0fe --- /dev/null +++ b/applications/incident-room/priv/static/app.css @@ -0,0 +1 @@ +*{box-sizing:border-box}body{margin:0;background:#f4f6f8;color:#172b3a;font:16px/1.5 system-ui,sans-serif}a{color:inherit;text-decoration:none}.page{max-width:1180px;margin:auto;padding:30px 36px}.brand{font-weight:750;font-size:20px}header{display:flex;align-items:center;justify-content:space-between;padding-bottom:28px;border-bottom:1px solid #dde4e9}.account{display:flex;gap:22px;align-items:center;font-size:14px}button{border:0;border-radius:7px;background:#235ee7;color:white;padding:11px 17px;font:inherit;font-weight:650;cursor:pointer}button:hover{background:#1949b8}.quiet{color:#496270;background:transparent;padding:0;font-size:14px}.quiet:hover{background:transparent;text-decoration:underline}input,textarea{display:block;width:100%;padding:12px;border:1px solid #c8d3de;border-radius:7px;background:white;font:inherit;color:inherit}label{display:block;font-size:14px;font-weight:600;margin:18px 0 10px}label input,label textarea{margin-top:7px}.eyebrow{font-size:11px;letter-spacing:1.6px;font-weight:800;color:#607583}h1{font-size:34px;line-height:1.2;margin:14px 0}h2{font-size:20px;margin:0 0 18px}h3{font-size:14px}p{margin:10px 0}.intro{padding:40px 0 25px}.intro p,.muted,footer{color:#657887}.open-form{display:flex;gap:12px;max-width:740px;margin-bottom:30px}.open-form input{flex:1}.rooms{display:grid;gap:14px}.room-card{display:flex;justify-content:space-between;align-items:center;background:white;border:1px solid #dce4eb;border-radius:10px;padding:22px}.room-card h2{margin:10px 0 0}.status{font-size:12px;display:inline-block;border-radius:20px;padding:4px 11px;background:#fff0d2;color:#94631a;font-weight:750}.status.monitoring{background:#e3edff;color:#2856a7}.status.resolved{background:#dbf2e4;color:#237049}.incident-head{display:flex;justify-content:space-between;align-items:center;padding:36px 0}.incident-head .muted{font-size:12px;margin-left:12px}.live-badge{font-size:13px;color:#288058;background:#e4f4eb;border-radius:30px;padding:8px 14px}.workspace{display:grid;grid-template-columns:1fr 340px;gap:32px}.workspace>div,aside{background:white;border:1px solid #dce4eb;border-radius:10px;padding:26px}.timeline{list-style:none;padding:0;margin:0}.timeline li{padding:18px 0;border-bottom:1px solid #e9edf1}.entry-meta{display:flex;gap:12px;align-items:center;font-size:12px;color:#687d8b}.entry-meta strong{color:#243c4d}.entry-meta span{margin-left:auto;font-size:10px;text-transform:uppercase}.timeline p{white-space:pre-wrap;overflow-wrap:anywhere}.transitions{margin:26px 0}.transitions button{margin:4px 8px 4px 0;background:#e9eff9;color:#31547c}.error{color:#8d2b2b;background:#fff0f0;padding:12px 16px;border-radius:7px}.notice{color:#22623d;background:#e8f5ed;padding:12px 16px;border-radius:7px}footer{font-size:12px;margin:36px 0}.sign-in{max-width:460px;margin:80px auto;padding:32px;background:white;border:1px solid #dce4eb;border-radius:12px}.sign-in h1{font-size:29px}.sign-in>p{color:#657887;font-size:14px}.sign-in button{width:100%;margin-top:16px}.sign-in .muted{margin-top:24px;font-size:12px}@media(max-width:800px){.workspace{grid-template-columns:1fr}.page{padding:24px 18px}.incident-head{align-items:start;gap:16px}.open-form{flex-direction:column}.entry-meta{flex-wrap:wrap}.sign-in{margin:36px 18px}} diff --git a/applications/incident-room/scripts/browser_acceptance.py b/applications/incident-room/scripts/browser_acceptance.py new file mode 100644 index 00000000..e1940eeb --- /dev/null +++ b/applications/incident-room/scripts/browser_acceptance.py @@ -0,0 +1,254 @@ +"""Real Chromium/LiveView acceptance against a dedicated seeded Cloud fixture. + +Install Playwright in a VM, then run with the README runtime variables and the +seeded CASEY/MORGAN passwords. Output contains counts, never WebSocket contents. +""" + +import asyncio +import json +import os +from pathlib import Path +import signal +import subprocess +import time +import urllib.request +from playwright.async_api import async_playwright, expect + +ORIGIN = os.environ.get("APP_ORIGIN", "http://127.0.0.1:4000") +OUTPUT = Path(os.environ.get("EVIDENCE_DIR", "/tmp/incident-browser-evidence")) +OUTPUT.mkdir(parents=True, exist_ok=True) + + +def start_server(): + # Do not give the HTTP process owner/admin/seed credentials. + keys = [ + "PATH", + "HOME", + "LANG", + "PGHOST", + "PGPORT", + "PGDATABASE", + "PGSSLROOTCERT", + "SECRET_KEY_BASE", + "APP_ORIGIN", + ] + env = {key: os.environ[key] for key in keys if key in os.environ} + env.update( + PGUSER="incident_app", PGPASSWORD=os.environ["APP_PASSWORD"], PHX_SERVER="true" + ) + log = open(OUTPUT / "server.log", "a") + process = subprocess.Popen( + ["mix", "phx.server"], env=env, stdout=log, stderr=log, start_new_session=True + ) + for _ in range(150): + try: + with urllib.request.urlopen(ORIGIN + "/sign-in", timeout=2) as response: + if response.status == 200: + return process, log + except (OSError, TimeoutError): + time.sleep(0.2) + if process.poll() is not None: + raise RuntimeError("Server exited; inspect the private server log") + raise RuntimeError("Server did not start") + + +def stop_server(process, log): + if process.poll() is not None: + log.close() + return + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + log.close() + + +async def sign_in(page, email, password): + await page.goto(ORIGIN + "/sign-in") + await page.locator('input[name="session[email]"]').fill(email) + await page.locator('input[name="session[password]"]').fill(password) + await page.get_by_role("button", name="Sign in", exact=True).click() + await page.wait_for_url(ORIGIN + "/") + await page.wait_for_function("window.liveSocket && window.liveSocket.isConnected()") + + +async def push(page, event, payload): + await page.evaluate( + """([event, value]) => { + window.liveSocket.execJS(document.querySelector('[data-phx-main]'), + JSON.stringify([['push', {event, value}]])); + }""", + [event, payload], + ) + + +async def main(): + server, log = start_server() + try: + async with async_playwright() as playwright: + browser = await playwright.chromium.launch() + one = await browser.new_context(viewport={"width": 1360, "height": 1000}) + two = await browser.new_context(viewport={"width": 1360, "height": 1000}) + anonymous = await browser.new_context() + casey, morgan, signed_out = ( + await one.new_page(), + await two.new_page(), + await anonymous.new_page(), + ) + frames = {"casey": 0, "morgan": 0} + for page, label in [(casey, "casey"), (morgan, "morgan")]: + page.on( + "websocket", + lambda socket, label=label: socket.on( + "framereceived", + lambda _: frames.__setitem__(label, frames[label] + 1), + ), + ) + await sign_in( + casey, "casey@example.test", os.environ["SEED_CASEY_PASSWORD"] + ) + await sign_in( + morgan, "morgan@example.test", os.environ["SEED_MORGAN_PASSWORD"] + ) + title = "API recovery " + str(time.time_ns()) + await casey.locator('input[name="incident[title]"]').fill(title) + await casey.get_by_role("button", name="Open incident").click() + await casey.wait_for_url("**/incidents/*") + room = casey.url + await morgan.goto(room) + await expect(morgan.get_by_role("heading", name=title)).to_be_visible() + await expect(morgan.locator(".live-badge")).to_have_text("● Live room") + response = await anonymous.request.post( + ORIGIN + "/sign-in", + form={"email": "casey@example.test", "password": "no-token"}, + ) + assert response.status == 403 + print("PASS HTTP mutation without CSRF token returns native403") + await signed_out.goto(room) + await expect(signed_out).to_have_url(ORIGIN + "/sign-in") + print("PASS signed-out HTTP room read redirects before mount") + + await morgan.locator("textarea").fill("Unsaved responder draft") + await casey.locator("textarea").fill("Errors are falling after rollback") + await casey.get_by_role("button", name="Add note").click() + await expect(casey.locator("textarea")).to_have_value("") + await expect(morgan.locator("#timeline")).to_contain_text( + "Errors are falling after rollback" + ) + await expect(morgan.locator("textarea")).to_have_value( + "Unsaved responder draft" + ) + print( + "PASS committed note broadcasts to second browser; own form clears, foreign draft survives" + ) + + before = await casey.locator("#timeline li").count() + await push( + casey, + "note", + { + "note": { + "body": "Forged author", + "author_id": "00000000-0000-0000-0000-000000000002", + } + }, + ) + await expect(casey.get_by_role("alert")).to_contain_text("not saved") + assert await casey.locator("#timeline li").count() == before + await push(casey, "note", {"note": []}) + await expect(casey.get_by_role("alert")).to_contain_text("not saved") + await push(casey, "transition", {"status": "resolved", "version": "0"}) + await expect(casey.get_by_role("alert")).to_contain_text("not saved") + await expect(casey.locator("#incident-status")).to_have_text( + "Investigating" + ) + print( + "PASS forged author, malformed payload and invalid transition leave timeline unchanged" + ) + + await asyncio.gather( + push(casey, "transition", {"status": "monitoring", "version": "0"}), + push(morgan, "transition", {"status": "monitoring", "version": "0"}), + ) + await expect(casey.locator("#incident-status")).to_have_text("Monitoring") + await expect(morgan.locator("#incident-status")).to_have_text("Monitoring") + await expect(casey.locator("#timeline")).to_contain_text( + "Status changed from investigating to monitoring" + ) + assert await casey.locator("#timeline li").count() == before + 1 + print( + "PASS two browser expected-version race commits exactly one transition entry" + ) + + await morgan.evaluate("window.liveSocket.disconnect()") + await casey.locator("textarea").fill("Missed broadcast while offline") + await casey.get_by_role("button", name="Add note").click() + await expect(casey.locator("#timeline")).to_contain_text( + "Missed broadcast while offline" + ) + assert ( + "Missed broadcast while offline" + not in await morgan.locator("#timeline").inner_text() + ) + await morgan.evaluate("window.liveSocket.connect()") + await expect(morgan.locator("#timeline")).to_contain_text( + "Missed broadcast while offline" + ) + print( + "PASS reconnect rehydrates committed data after deliberately missed broadcast" + ) + await morgan.screenshot( + path=str(OUTPUT / "incident-room.png"), full_page=True + ) + + old_pid = server.pid + stop_server(server, log) + server, log = start_server() + assert old_pid != server.pid + await casey.reload() + await expect(casey.locator(".live-badge")).to_have_text("● Live room") + await expect(casey.locator("#timeline")).to_contain_text( + "Missed broadcast while offline" + ) + await expect(casey.locator("#incident-status")).to_have_text("Monitoring") + await casey.locator("textarea").fill( + "Session still authorized after restart" + ) + await casey.get_by_role("button", name="Add note").click() + await expect(casey.locator("#timeline")).to_contain_text( + "Session still authorized after restart" + ) + print( + "PASS real server process restart retains DB timeline, state and authenticated session" + ) + + duplicate_tab = await one.new_page() + await duplicate_tab.goto(room) + await expect(duplicate_tab.locator(".live-badge")).to_have_text( + "● Live room" + ) + preserved_cookie = await one.cookies() + await casey.get_by_role("button", name="Sign out", exact=True).click() + await expect(casey).to_have_url(ORIGIN + "/sign-in") + await duplicate_tab.evaluate("window.liveSocket.connect()") + await duplicate_tab.reload() + await expect(duplicate_tab).to_have_url(ORIGIN + "/sign-in") + replay = await browser.new_context() + await replay.add_cookies(preserved_cookie) + replay_page = await replay.new_page() + await replay_page.goto(room) + await expect(replay_page).to_have_url(ORIGIN + "/sign-in") + print( + "PASS independent replay of pre-logout cookie is rejected after DB revocation" + ) + assert frames["casey"] > 0 and frames["morgan"] > 0 + print("WebSocket received frame counts: " + json.dumps(frames)) + await browser.close() + finally: + stop_server(server, log) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/applications/incident-room/scripts/migrate.exs b/applications/incident-room/scripts/migrate.exs new file mode 100644 index 00000000..e975d4a1 --- /dev/null +++ b/applications/incident-room/scripts/migrate.exs @@ -0,0 +1,8 @@ +# Run with the schema owner's credentials, never the server's runtime role. +Ecto.Migrator.run( + IncidentRoom.Repo, + Application.app_dir(:incident_room, "priv/repo/migrations"), + :up, + all: true, + prefix: "incident_room" +) diff --git a/applications/incident-room/sql/bootstrap.sql b/applications/incident-room/sql/bootstrap.sql new file mode 100644 index 00000000..060fb7d5 --- /dev/null +++ b/applications/incident-room/sql/bootstrap.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +CREATE ROLE incident_migration LOGIN PASSWORD :'migration_password'; +CREATE ROLE incident_app LOGIN PASSWORD :'app_password'; +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +CREATE SCHEMA incident_room AUTHORIZATION incident_migration; +GRANT USAGE ON SCHEMA incident_room TO incident_app; diff --git a/applications/incident-room/sql/cleanup.sql b/applications/incident-room/sql/cleanup.sql new file mode 100644 index 00000000..ba63e9e7 --- /dev/null +++ b/applications/incident-room/sql/cleanup.sql @@ -0,0 +1,4 @@ +\set ON_ERROR_STOP on +DROP SCHEMA IF EXISTS incident_room CASCADE; +DROP ROLE IF EXISTS incident_app; +DROP ROLE IF EXISTS incident_migration; diff --git a/applications/incident-room/sql/grants.sql b/applications/incident-room/sql/grants.sql new file mode 100644 index 00000000..e4d4b259 --- /dev/null +++ b/applications/incident-room/sql/grants.sql @@ -0,0 +1,5 @@ +\set ON_ERROR_STOP on +GRANT SELECT ON incident_room.users TO incident_app; +GRANT SELECT, INSERT, DELETE ON incident_room.sessions TO incident_app; +GRANT SELECT, INSERT, UPDATE ON incident_room.incidents TO incident_app; +GRANT SELECT, INSERT ON incident_room.entries TO incident_app; diff --git a/applications/incident-room/test/cloud_test.exs b/applications/incident-room/test/cloud_test.exs new file mode 100644 index 00000000..8422372f --- /dev/null +++ b/applications/incident-room/test/cloud_test.exs @@ -0,0 +1,276 @@ +defmodule IncidentRoom.CloudTest do + use ExUnit.Case, async: false + import Ecto.Query + import ExUnit.CaptureLog + alias IncidentRoom.{Auth, Entry, Incidents, Repo, User} + @moduletag :cloud + + setup do + token = Auth.create_session(Repo.get_by!(User, email: "casey@example.test")) + on_exit(fn -> Auth.delete_session(token) end) + %{token: token} + end + + test "same-endpoint Postgrex accepts verified TLS and rejects wrong CA and hostname" do + assert %{rows: [[true]]} = + Repo.query!("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + + opts = Application.fetch_env!(:incident_room, Repo) + rejected_tls(opts, :cacertfile, ~c"/etc/ssl/certs/ca-certificates.crt", "unknown_ca") + + rejected_tls( + opts, + :server_name_indication, + ~c"wrong.example.invalid", + "hostname_check_failed" + ) + + assert %{rows: [[1]]} = Repo.query!("SELECT 1") + end + + test "runtime cannot run DDL, read migration history, or rewrite identity and timeline", %{ + token: token + } do + {:ok, incident} = Incidents.open(token, %{"title" => "Privilege checks"}) + id = Ecto.UUID.dump!(incident.id) + + for {sql, params} <- [ + {"CREATE TABLE incident_room.forbidden (id integer)", []}, + {"SELECT * FROM incident_room.schema_migrations", []}, + {"UPDATE incident_room.users SET name = name", []}, + {"UPDATE incident_room.entries SET body = body WHERE incident_id = $1", [id]}, + {"DELETE FROM incident_room.entries WHERE incident_id = $1", [id]} + ] do + assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} = + Repo.query(sql, params) + end + end + + test "concurrent expected-version transitions produce one committed status entry", %{ + token: token + } do + {:ok, incident} = Incidents.open(token, %{"title" => "Concurrent response"}) + + results = + 1..2 + |> Enum.map(fn _ -> + Task.async(fn -> + Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0}) + end) + end) + |> Enum.map(&Task.await(&1, 30_000)) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &match?({:error, :stale_version}, &1)) == 1 + assert %{status: "monitoring", version: 1} = Incidents.get(incident.id) + assert Enum.count(Incidents.timeline(incident.id), &(&1.kind == "status")) == 1 + end + + test "invalid transition and forged author roll back; resolved room rejects notes", %{ + token: token + } do + {:ok, incident} = Incidents.open(token, %{"title" => "Terminal room"}) + + assert {:error, _} = + Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 0}) + + assert {:error, _} = + Incidents.note(token, incident.id, %{ + "body" => "Forged", + "author_id" => Ecto.UUID.generate() + }) + + assert [%{kind: "opened"}] = Incidents.timeline(incident.id) + assert %{version: 0, status: "investigating"} = Incidents.get(incident.id) + + {:ok, _} = + Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0}) + + {:ok, _} = Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 1}) + assert {:error, :resolved} = Incidents.note(token, incident.id, %{"body" => "Too late"}) + assert length(Incidents.timeline(incident.id)) == 3 + end + + test "session identity is authoritative and revocation denies subsequent writes", %{ + token: token + } do + {:ok, incident} = Incidents.open(token, %{"title" => "Session authority"}) + {:ok, _} = Incidents.note(token, incident.id, %{"body" => "Verified author"}) + assert List.last(Incidents.timeline(incident.id)).author.email == "casey@example.test" + Auth.delete_session(token) + assert {:error, _} = Incidents.note(token, incident.id, %{"body" => "Revoked"}) + assert {:error, _} = Incidents.open(nil, %{"title" => "Signed out"}) + assert length(Incidents.timeline(incident.id)) == 2 + end + + test "bounded timeline retains the newest entry and Unicode bounds agree with SQL", %{ + token: token + } do + assert {:error, :invalid_input} = + Incidents.open(token, %{"title" => String.duplicate("e\u0301", 81)}) + + {:ok, incident} = Incidents.open(token, %{"title" => String.duplicate("e\u0301", 80)}) + author = Auth.user(token) + now = DateTime.utc_now() + + rows = + for n <- 1..205, + do: %{ + id: Ecto.UUID.generate(), + incident_id: incident.id, + author_id: author.id, + kind: "note", + body: "Batch #{n}", + inserted_at: DateTime.add(now, n, :microsecond) + } + + Repo.insert_all(Entry, rows) + timeline = Incidents.timeline(incident.id) + assert length(timeline) == 200 + assert List.first(timeline).body == "Batch 6" + assert List.last(timeline).body == "Batch 205" + assert timeline == Enum.sort_by(timeline, &{&1.inserted_at, &1.id}) + end + + test "held database lock prevents writes and PubSub until committed", %{token: token} do + {:ok, incident} = Incidents.open(token, %{"title" => "Commit boundary"}) + Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{incident.id}") + parent = self() + + holder = + Task.async(fn -> + Repo.transact(fn -> + Repo.one!( + from i in IncidentRoom.Incident, where: i.id == ^incident.id, lock: "FOR UPDATE" + ) + + send(parent, :locked) + + receive do + :release -> {:ok, :released} + after + 15_000 -> {:error, :timeout} + end + end) + end) + + assert_receive :locked, 15_000 + + writer = + Task.async(fn -> + Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0}) + end) + + assert wait_for_blocking() + refute_receive :committed, 200 + assert Task.yield(writer, 0) == nil + send(holder.pid, :release) + assert {:ok, :released} = Task.await(holder, 30_000) + assert {:ok, %{version: 1}} = Task.await(writer, 30_000) + assert_receive :committed, 5_000 + assert length(Incidents.timeline(incident.id)) == 2 + + assert {:error, :stale_version} = + Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 0}) + + refute_receive :committed, 200 + end + + test "entry failure after status update rolls back state and emits no PubSub", %{token: token} do + {:ok, incident} = Incidents.open(token, %{"title" => "Forced transaction rollback"}) + Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{incident.id}") + opts = Application.fetch_env!(:incident_room, Repo) + + owner_opts = + opts + |> Keyword.put(:username, "incident_migration") + |> Keyword.put(:password, System.fetch_env!("MIGRATION_PASSWORD")) + |> Keyword.put(:pool_size, 1) + + {:ok, owner} = Postgrex.start_link(owner_opts) + + Postgrex.query!( + owner, + """ + CREATE FUNCTION incident_room.fail_entry_test() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'forced_timeline_failure' USING ERRCODE = '23514', CONSTRAINT = 'forced_timeline_failure'; + END; + $$ + """, + [] + ) + + # UUID is generated by Ecto and contains only hex digits/hyphens. This DDL is + # fixture-owner-only; production queries remain parameterized. + Postgrex.query!( + owner, + """ + CREATE TRIGGER fail_entry_test BEFORE INSERT ON incident_room.entries + FOR EACH ROW WHEN (NEW.incident_id = '#{incident.id}'::uuid AND NEW.kind = 'status') + EXECUTE FUNCTION incident_room.fail_entry_test() + """, + [] + ) + + try do + try do + Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0}) + flunk("Expected the timeline write to fail after the incident update") + rescue + error in [Postgrex.Error, Ecto.ConstraintError] -> + assert Exception.message(error) =~ "forced_timeline_failure" + end + + assert %{status: "investigating", version: 0} = Incidents.get(incident.id) + assert [%{kind: "opened"}] = Incidents.timeline(incident.id) + refute_receive :committed, 200 + after + Postgrex.query!(owner, "DROP TRIGGER fail_entry_test ON incident_room.entries", []) + Postgrex.query!(owner, "DROP FUNCTION incident_room.fail_entry_test()", []) + GenServer.stop(owner, :normal, 30_000) + end + end + + defp wait_for_blocking do + Enum.reduce_while(1..100, false, fn _, _ -> + Repo.query!("SELECT pg_stat_clear_snapshot()") + + %{rows: [[count]]} = + Repo.query!( + "SELECT count(*) FROM pg_stat_activity WHERE usename = current_user AND cardinality(pg_blocking_pids(pid)) > 0" + ) + + if count > 0, + do: {:halt, true}, + else: + ( + Process.sleep(50) + {:cont, false} + ) + end) + end + + defp rejected_tls(opts, key, value, expected) do + ssl = Keyword.put(opts[:ssl], key, value) + + connection = + opts + |> Keyword.put(:ssl, ssl) + |> Keyword.put(:backoff_type, :stop) + |> Keyword.put(:pool_size, 1) + |> Keyword.put(:max_restarts, 0) + + previous = Process.flag(:trap_exit, true) + + log = + capture_log(fn -> + {:ok, pid} = Postgrex.start_link(connection) + monitor = Process.monitor(pid) + assert_receive {:DOWN, ^monitor, :process, ^pid, _reason}, 20_000 + end) + + Process.flag(:trap_exit, previous) + assert log =~ expected + end +end diff --git a/applications/incident-room/test/domain_test.exs b/applications/incident-room/test/domain_test.exs new file mode 100644 index 00000000..635a4c86 --- /dev/null +++ b/applications/incident-room/test/domain_test.exs @@ -0,0 +1,29 @@ +defmodule IncidentRoom.DomainTest do + use ExUnit.Case, async: true + alias IncidentRoom.Incidents + + test "status graph closes resolved rooms" do + assert Incidents.valid_transition?("investigating", "monitoring") + assert Incidents.valid_transition?("monitoring", "resolved") + assert Incidents.valid_transition?("monitoring", "investigating") + refute Incidents.valid_transition?("investigating", "resolved") + refute Incidents.valid_transition?("resolved", "investigating") + end + + test "notes have nonblank bounded text and no NUL" do + assert Incidents.valid_text?("Observed a recovery", 2000) + refute Incidents.valid_text?(" ", 2000) + refute Incidents.valid_text?(String.duplicate("x", 2001), 2000) + refute Incidents.valid_text?("hidden" <> <<0>>, 2000) + end + + test "Unicode limits count PostgreSQL codepoints, not graphemes" do + assert Incidents.valid_text?(String.duplicate("e\u0301", 80), 160) + refute Incidents.valid_text?(String.duplicate("e\u0301", 81), 160) + assert Incidents.valid_text?(String.duplicate("🧑‍💻", 53), 160) + refute Incidents.valid_text?(String.duplicate("🧑‍💻", 54), 160) + assert {:error, :invalid_input} = Incidents.open(nil, []) + assert {:error, :invalid_input} = Incidents.note(nil, nil, "body") + assert {:error, :invalid_input} = Incidents.transition(nil, nil, nil) + end +end diff --git a/applications/incident-room/test/test_helper.exs b/applications/incident-room/test/test_helper.exs new file mode 100644 index 00000000..b51f87a3 --- /dev/null +++ b/applications/incident-room/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start(exclude: [cloud: true])