From 9c0f33e6ca3570161c8b431db569478ea1e87c35 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Thu, 29 Oct 2026 10:15:21 +0000 Subject: [PATCH] yak(behavior-tests-web-controllers-and-auth-edge-cases-o04i): behavior tests: web controllers and auth edge cases --- app/lib/firehose_web/router.ex | 5 ++-- app/mix.exs | 12 ++++----- .../firehose_web/controllers/blog_test.exs | 23 ++++++++++++++++ .../controllers/page_controller_test.exs | 11 ++++++++ .../user_session_controller_test.exs | 26 ++++++++++++++++++- app/test/firehose_web/user_auth_test.exs | 23 ++++++++++++++++ 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex index 61e1ce7..797207c 100644 --- a/app/lib/firehose_web/router.ex +++ b/app/lib/firehose_web/router.ex @@ -14,9 +14,8 @@ defmodule FirehoseWeb.Router do plug :fetch_current_scope_for_user end - pipeline :api do - plug :accepts, ["json"] - end + # No :api pipeline: the JSON blog API is served by + # forward "/api/blog/...", Blogex.Router below, which sets its own content types. scope "/", FirehoseWeb do pipe_through :browser diff --git a/app/mix.exs b/app/mix.exs index 93c78a6..3f00c10 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -193,11 +193,11 @@ defmodule Firehose.MixProject do # not the floor. # # Measured 2026-10-28 (PostgreSQL on localhost:5432, exclusions below): - # 87.56% after the editorial-LiveView behavior tests (86.97% before them, and - # 86.79% / 86.17% at earlier exclusion revisions), identical via - # `mix test --cover` and via `mix test --cover --export-coverage default` + - # `mix test.coverage`. Floor is the integer below that — so a floor of 87 - # means "at least 87.00%". + # 88.19% after the web-controller/auth edge-case behavior tests (87.56% after + # the editorial-LiveView ones, 86.97% before them, and 86.79% / 86.17% at + # earlier exclusion revisions), identical via `mix test --cover` and via + # `mix test --cover --export-coverage default` + `mix test.coverage`. Floor is + # the integer below that — so a floor of 88 means "at least 88.00%". # # `test.coverage` deletes stale exports before sampling: `mix test.coverage` # imports *every* .coverdata it finds, so leftover exports from earlier runs @@ -243,7 +243,7 @@ defmodule Firehose.MixProject do # that is where the behavior-test work shows up as a higher floor. defp test_coverage do [ - summary: [threshold: 87], + summary: [threshold: 88], ignore_modules: [ Firehose.Repo, Firehose.Mailer, diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs index 1284e83..9764e3d 100644 --- a/app/test/firehose_web/controllers/blog_test.exs +++ b/app/test/firehose_web/controllers/blog_test.exs @@ -20,6 +20,29 @@ defmodule FirehoseWeb.BlogTest do body = visit_blog_page(conn, "engineering", "/hello-world") assert body =~ "Hello World" end + + test "GET /blog/engineering?page=2 shows the next page of older posts", %{conn: conn} do + # Page size is 10 (`Blogex.Blog.paginate/2`, blogex/lib/blogex/blog.ex:121) + # and the test env lists drafts (`show_drafts: true`, + # app/config/config.exs), so the 34 visible engineering posts (the + # 2099-dated future post is excluded by `all_posts/0`) fill 4 pages. + # Page 2, date-descending, runs 2026-07-20 .. 2026-05-14. + body = visit_blog_page(conn, "engineering", "?page=2") + + # The pager reports the page the reader asked for, not page 1 + # (markup: blogex/lib/blogex/components.ex). + assert body =~ ~r/Page 2 of \d+/ + + # "Synthetic TDD" (2026-07-13) sits mid-page-2: five posts of headroom + # before new publications would push it to page 3. Matched as an anchor + # because the description of the page-2 post above it also says + # "Synthetic TDD". + assert body =~ ~s|>Synthetic TDD| + + # The newest post is unambiguously on page 1, so page 2 is a genuinely + # different set of posts rather than page 1 relabelled. + refute body =~ "I reduced the attack surface" + end end describe "release notes blog (HTML)" do diff --git a/app/test/firehose_web/controllers/page_controller_test.exs b/app/test/firehose_web/controllers/page_controller_test.exs index 06e1374..e9472a7 100644 --- a/app/test/firehose_web/controllers/page_controller_test.exs +++ b/app/test/firehose_web/controllers/page_controller_test.exs @@ -21,6 +21,17 @@ defmodule FirehoseWeb.PageControllerTest do end end + describe "GET /contact" do + test "renders the contact page inside the app layout", %{conn: conn} do + body = conn |> get(~p"/contact") |> html_response(200) + + # Page content (contact.html.heex) ... + assert body =~ "Get in touch" + # ... wrapped by the app layout, which is where the site nav lives. + assert body =~ ~s|/blog/engineering| + end + end + describe "GET / when authenticated" do setup :register_and_log_in_user diff --git a/app/test/firehose_web/controllers/user_session_controller_test.exs b/app/test/firehose_web/controllers/user_session_controller_test.exs index 25387e3..0573aff 100644 --- a/app/test/firehose_web/controllers/user_session_controller_test.exs +++ b/app/test/firehose_web/controllers/user_session_controller_test.exs @@ -61,13 +61,37 @@ defmodule FirehoseWeb.UserSessionControllerTest do assert html =~ "Keep me logged in on this device" end - test "raises error for invalid token", %{conn: conn} do + # The two ways a magic link can be dead arrive here through different arms + # of `Firehose.Accounts.get_user_by_magic_link_token/1`: a token that is + # not base64 at all fails to decode (`UserToken.verify_magic_link_token_query/1` + # returns `:error`), while a well-formed token simply matches no row. Both + # must land the visitor back on the log-in page with the error flash, not + # raise or 404. + test "sends the visitor back to log-in when the magic link token is not valid base64", + %{conn: conn} do + # 13 characters is not decodable with `padding: false`; the 0 hits on the + # magic-link `:error` line in cover/ show this test is what covers it. + assert :error = Base.url_decode64("invalid-token", padding: false) + response = conn |> get(~p"/users/log-in/invalid-token") assert redirected_to(response) == ~p"/users/log-in" assert Phoenix.Flash.get(response.assigns.flash, :error) == "Magic link is invalid or it has expired." end + + test "sends the visitor back to log-in for a decodable but unknown token", %{conn: conn} do + # The assertion keeps this case distinct from the not-base64 one above: + # if this token ever stopped decoding, the two tests would silently pin + # the same arm. + assert {:ok, _decoded} = Base.url_decode64("invalidtoken", padding: false) + + response = conn |> get(~p"/users/log-in/invalidtoken") + assert redirected_to(response) == ~p"/users/log-in" + + assert Phoenix.Flash.get(response.assigns.flash, :error) == + "Magic link is invalid or it has expired." + end end describe "POST /users/log-in - email and password" do diff --git a/app/test/firehose_web/user_auth_test.exs b/app/test/firehose_web/user_auth_test.exs index 56ed92e..f051567 100644 --- a/app/test/firehose_web/user_auth_test.exs +++ b/app/test/firehose_web/user_auth_test.exs @@ -363,4 +363,27 @@ defmodule FirehoseWeb.UserAuthTest do refute conn.status end end + + describe "a session token that no longer exists" do + # This is the plug-layer contract that keeps UserAuth's LiveView-side + # defences unreachable in this app: the router wraps /editor/dashboard in + # `pipe_through [:browser, :require_authenticated_user]`, so the plug + # redirects before `on_mount {UserAuth, :ensure_authenticated}` can run and + # its halt arm never fires for a stale token. The same goes for the nil + # scope fallbacks in `scope_from_session/1`. + test "is treated as logged out", %{conn: conn, user: user} do + conn = log_in_user(conn, user) + token = get_session(conn, :user_token) + assert Accounts.get_user_by_session_token(token) + + :ok = Accounts.delete_user_session_token(token) + + response = conn |> get(~p"/users/settings") + + assert redirected_to(response) == ~p"/users/log-in" + + assert Phoenix.Flash.get(response.assigns.flash, :error) == + "You must log in to access this page." + end + end end