diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index ef42ab09..579f7f4f 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -34,7 +34,7 @@ jobs: # Specify the OTP and Elixir versions to use when building # and running the workflow steps. matrix: - otp: [ '27.2.1' ] # Define the OTP version [required] + otp: [ '27.3.4.13' ] # Define the OTP version [required] elixir: [ '1.18.2-otp-27' ] # Define the elixir version [required] steps: # Step: Check out the code. @@ -86,7 +86,7 @@ jobs: # Specify the OTP and Elixir versions to use when building # and running the workflow steps. matrix: - otp: [ '27.2.1' ] # Define the OTP version [required] + otp: [ '27.3.4.13' ] # Define the OTP version [required] elixir: [ '1.18.2-otp-27' ] # Define the elixir version [required] steps: # Step: Check out the code. diff --git a/.tool-versions b/.tool-versions index e4c737a8..793176ef 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ elixir 1.18.2-otp-27 -erlang 27.2.1 +erlang 27.3.4.13 diff --git a/Dockerfile b/Dockerfile index aae07479..350ce41e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,8 @@ # - Ex: hexpm/elixir:1.14.3-erlang-24.3.4.10-debian-bullseye-20230227-slim # ARG ELIXIR_VERSION=1.18.2 -ARG OTP_VERSION=27.2.1 -ARG DEBIAN_VERSION=bookworm-20250113-slim +ARG OTP_VERSION=27.3.4.13 +ARG DEBIAN_VERSION=bookworm-20260518-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" diff --git a/config/runtime.exs b/config/runtime.exs index dd0970a2..34d4255c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -131,9 +131,24 @@ if System.get_env("PHX_SERVER") do config :pears, PearsWeb.Endpoint, server: true end -config :pears, slack_client_id: System.fetch_env!("SLACK_CLIENT_ID") -config :pears, slack_client_secret: System.fetch_env!("SLACK_CLIENT_SECRET") -config :pears, slack_signing_secret: System.fetch_env!("SLACK_SIGNING_SECRET") +# Slack credentials are required in production (fail fast if missing), but in +# dev/test we fall back to placeholders so the app can boot without the secrets +# being present (e.g. in CI). Tests sign requests with whatever is configured +# here, so a fixed placeholder is sufficient. +if config_env() == :prod do + config :pears, slack_client_id: System.fetch_env!("SLACK_CLIENT_ID") + config :pears, slack_client_secret: System.fetch_env!("SLACK_CLIENT_SECRET") + config :pears, slack_signing_secret: System.fetch_env!("SLACK_SIGNING_SECRET") +else + config :pears, slack_client_id: System.get_env("SLACK_CLIENT_ID", "test-slack-client-id") + + config :pears, + slack_client_secret: System.get_env("SLACK_CLIENT_SECRET", "test-slack-client-secret") + + config :pears, + slack_signing_secret: System.get_env("SLACK_SIGNING_SECRET", "test-slack-signing-secret") +end + config :pears, slack_oauth_redirect_uri: System.get_env("SLACK_OAUTH_URL") case System.fetch_env("OTEL_EXPORTER") do diff --git a/lib/pears/slack.ex b/lib/pears/slack.ex index ce7a92c5..5ee5e695 100644 --- a/lib/pears/slack.ex +++ b/lib/pears/slack.ex @@ -216,6 +216,13 @@ defmodule Pears.Slack do O11y.set_error(error) {:error, error} end + rescue + # The Slack HTTP client raises on transport-level failures (e.g. a TLS + # handshake alert). Capture it as an error rather than letting it crash the + # caller (which previously took down the record-pears LiveView handler). + exception -> + O11y.set_error(exception) + {:error, exception} end defp fetch_tokens(slack_code) do diff --git a/lib/pears_web/live/pairing_board_live.ex b/lib/pears_web/live/pairing_board_live.ex index e4d65129..4628f105 100644 --- a/lib/pears_web/live/pairing_board_live.ex +++ b/lib/pears_web/live/pairing_board_live.ex @@ -108,12 +108,12 @@ defmodule PearsWeb.PairingBoardLive do case Pears.record_pears(team_name) do {:ok, _updated_team} -> - Slack.send_daily_pears_summary(team_name) + # Post the Slack summary off the reply path so a slow or failing Slack + # call can't block the response or crash this handler. The board itself + # refreshes via the PubSub team-updated broadcast that record_pears fires. + send(self(), {:post_daily_pears_summary, team_name}) - {:noreply, - socket - |> put_flash(:info, "Today's assigned pears have been recorded!") - |> push_navigate(to: "/")} + {:noreply, put_flash(socket, :info, "Today's assigned pears have been recorded!")} {:error, changeset} -> Pears.O11y.set_changeset_errors(changeset) @@ -125,6 +125,7 @@ defmodule PearsWeb.PairingBoardLive do error -> O11y.set_error(error) + {:noreply, put_flash(socket, :error, "Sorry! Something went wrong, please try again.")} end end @@ -277,6 +278,22 @@ defmodule PearsWeb.PairingBoardLive do {:noreply, put_flash(socket, type, message)} end + @impl true + def handle_info({:post_daily_pears_summary, team_name}, socket) do + case Slack.send_daily_pears_summary(team_name) do + {:error, _reason} -> + {:noreply, + put_flash( + socket, + :error, + "Today's pears were recorded, but the Slack summary could not be posted." + )} + + _ -> + {:noreply, socket} + end + end + defp team(socket), do: socket.assigns.team defp team_name(socket), do: team(socket).name defp selected_pear(socket), do: socket.assigns.selected_pear diff --git a/mix.lock b/mix.lock index bb52ebec..3fe622b3 100644 --- a/mix.lock +++ b/mix.lock @@ -3,7 +3,6 @@ "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [: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.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.1", "e361261a0401d82dadc1ab7b969f91d250bf7577283e933fe8c5b72f8f5b3c46", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "81170177d5c2e280d12141a0b9d9e299bf731535e2d959982bdcd4cfe3c82865"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, diff --git a/test/pears/slack_test.exs b/test/pears/slack_test.exs index d90e759b..c8b0e01c 100644 --- a/test/pears/slack_test.exs +++ b/test/pears/slack_test.exs @@ -219,6 +219,19 @@ defmodule Pears.SlackTest do {:error, _} = Slack.send_message_to_team(name, "Hey, friends!") refute_receive {:send_message, _, _, _} end + + test "returns an error instead of raising when the slack client blows up", %{name: name} do + channel = %{id: "UXXXXXXX", name: "random"} + {:ok, _} = Slack.save_team_channel(Details.empty(), name, channel) + + # Simulates a transport-level failure such as the TLS handshake alert that + # crashed record-pears: the underlying client raises rather than returning. + expect(MockSlackClient, :send_message, fn _channel, _message, _token -> + raise "TLS client: ... Unsupported Certificate (key_usage_mismatch)" + end) + + assert {:error, _} = Slack.send_message_to_team(name, "Hey, friends!") + end end describe "send_daily_pears_summary" do diff --git a/test/pears_web/live/pairing_board_live_test.exs b/test/pears_web/live/pairing_board_live_test.exs index 8325cf41..bdacddd2 100644 --- a/test/pears_web/live/pairing_board_live_test.exs +++ b/test/pears_web/live/pairing_board_live_test.exs @@ -1,6 +1,12 @@ defmodule PearsWeb.TeamLiveTest do use PearsWeb.ConnCase, async: true import Phoenix.LiveViewTest + import Mox + + alias Pears.Slack + alias Pears.SlackFixtures + + setup :verify_on_exit! describe "when logged in" do setup :register_and_log_in_team @@ -12,6 +18,44 @@ defmodule PearsWeb.TeamLiveTest do end end + describe "recording pears when Slack is failing" do + setup :register_and_log_in_team + + setup %{team: team} do + stub(Pears.MockSlackClient, :retrieve_access_tokens, fn _code, _url -> + SlackFixtures.valid_token_response(%{access_token: "xoxb-test-token"}) + end) + + {:ok, _} = Slack.onboard_team(team.name, "valid_code") + + {:ok, _} = + Slack.save_team_channel(Slack.Details.empty(), team.name, %{ + id: "UXXXXXXX", + name: "random" + }) + + FeatureFlags.enable(:send_daily_pears_summary, for_actor: team) + :ok + end + + test "Save still records and warns the user instead of crashing the board", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/teams") + # The async Slack post runs in the LiveView process; allow the mock there. + allow(Pears.MockSlackClient, self(), view.pid) + + stub(Pears.MockSlackClient, :send_message, fn _channel, _message, _token -> + raise "TLS client: ... Unsupported Certificate (key_usage_mismatch)" + end) + + view |> element("button", "Save") |> render_click() + + html = render(view) + assert html =~ "recorded" + assert html =~ "Slack summary could not be posted" + assert Process.alive?(view.pid) + end + end + describe "when logged out" do test "when not logged in, redirects to login", %{conn: conn} do {:error, {:redirect, %{to: redirected_to}}} = live(conn, "/teams")