diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..d27a1db --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,44 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..50f281f --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..f242785 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..87b2735 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,12 @@ +{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:38:37.480901856Z","closed_at":"2026-04-01T20:38:37.480901856Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:39:26.605057721Z","closed_at":"2026-04-01T20:39:26.605057721Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"} +{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"} +{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:42:49.026878069Z","closed_at":"2026-04-01T21:42:49.026878069Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"} +{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.881106002Z","closed_at":"2026-04-01T21:47:19.881106002Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} +{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.87799142Z","closed_at":"2026-04-01T21:47:19.87799142Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/app/AGENTS.md b/app/AGENTS.md index 6f52c21..de4d711 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -44,6 +44,37 @@ custom classes must fully style the input - Focus on **delightful details** like hover effects, loading states, and smooth page transitions + +## Authentication + +- **Always** handle authentication flow at the router level with proper redirects +- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs: + - A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline + - A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated + - In both cases, a `@current_scope` is assigned to the Plug connection + - A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users +- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY** +- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign** +- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results +- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates +- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below** + +### Routes that require authentication + +Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug: + + scope "/", AppWeb do + pipe_through [:browser, :require_authenticated_user] + + get "/", MyControllerThatRequiresAuth, :index + end + +### Routes that work with or without authentication + +Controllers automatically have the `current_scope` available if they use the `:browser` pipeline. + + + diff --git a/app/config/config.exs b/app/config/config.exs index 93010aa..a32c72b 100644 --- a/app/config/config.exs +++ b/app/config/config.exs @@ -7,6 +7,19 @@ # General application configuration import Config +config :firehose, :scopes, + user: [ + default: true, + module: Firehose.Accounts.Scope, + assign_key: :current_scope, + access_path: [:user, :id], + schema_key: :user_id, + schema_type: :id, + schema_table: :users, + test_data_fixture: Firehose.AccountsFixtures, + test_setup_helper: :register_and_log_in_user + ] + config :firehose, ecto_repos: [Firehose.Repo], generators: [timestamp_type: :utc_datetime] diff --git a/app/config/runtime.exs b/app/config/runtime.exs index f0f4e40..162e9cb 100644 --- a/app/config/runtime.exs +++ b/app/config/runtime.exs @@ -20,6 +20,8 @@ if System.get_env("PHX_SERVER") do config :firehose, FirehoseWeb.Endpoint, server: true end +config :firehose, :allowed_registration_email, System.get_env("ALLOWED_REGISTRATION_EMAIL") + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || diff --git a/app/config/test.exs b/app/config/test.exs index ee30aa3..524ff6b 100644 --- a/app/config/test.exs +++ b/app/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used @@ -35,3 +38,5 @@ config :phoenix, :plug_init_mode, :runtime # Enable helpful, but potentially expensive runtime checks config :phoenix_live_view, enable_expensive_runtime_checks: true + +config :firehose, :allowed_registration_email, nil diff --git a/app/lib/firehose/accounts.ex b/app/lib/firehose/accounts.ex new file mode 100644 index 0000000..2850e5f --- /dev/null +++ b/app/lib/firehose/accounts.ex @@ -0,0 +1,297 @@ +defmodule Firehose.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Firehose.Repo + + alias Firehose.Accounts.{User, UserToken, UserNotifier} + + ## Database getters + + @doc """ + Gets a user by email. + + ## Examples + + iex> get_user_by_email("foo@example.com") + %User{} + + iex> get_user_by_email("unknown@example.com") + nil + + """ + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc """ + Gets a user by email and password. + + ## Examples + + iex> get_user_by_email_and_password("foo@example.com", "correct_password") + %User{} + + iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + nil + + """ + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = Repo.get_by(User, email: email) + if User.valid_password?(user, password), do: user + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.email_changeset(attrs) + |> Repo.insert() + end + + ## Settings + + @doc """ + Checks whether the user is in sudo mode. + + The user is in sudo mode when the last authentication was done no further + than 20 minutes ago. The limit can be given as second argument in minutes. + """ + def sudo_mode?(user, minutes \\ -20) + + def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do + DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute)) + end + + def sudo_mode?(_user, _minutes), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user email. + + See `Firehose.Accounts.User.email_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_email(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_email(user, attrs \\ %{}, opts \\ []) do + User.email_changeset(user, attrs, opts) + end + + @doc """ + Updates the user email using the given token. + + If the token matches, the user email is updated and the token is deleted. + """ + def update_user_email(user, token) do + context = "change:#{user.email}" + + Repo.transact(fn -> + with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), + %UserToken{sent_to: email} <- Repo.one(query), + {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})), + {_count, _result} <- + Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do + {:ok, user} + else + _ -> {:error, :transaction_aborted} + end + end) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for changing the user password. + + See `Firehose.Accounts.User.password_changeset/3` for a list of supported options. + + ## Examples + + iex> change_user_password(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_password(user, attrs \\ %{}, opts \\ []) do + User.password_changeset(user, attrs, opts) + end + + @doc """ + Updates the user password. + + Returns a tuple with the updated user, as well as a list of expired tokens. + + ## Examples + + iex> update_user_password(user, %{password: ...}) + {:ok, {%User{}, [...]}} + + iex> update_user_password(user, %{password: "too short"}) + {:error, %Ecto.Changeset{}} + + """ + def update_user_password(user, attrs) do + user + |> User.password_changeset(attrs) + |> update_user_and_delete_all_tokens() + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + + If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Gets the user with the given magic link token. + """ + def get_user_by_magic_link_token(token) do + with {:ok, query} <- UserToken.verify_magic_link_token_query(token), + {user, _token} <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc """ + Logs the user in by magic link. + + There are three cases to consider: + + 1. The user has already confirmed their email. They are logged in + and the magic link is expired. + + 2. The user has not confirmed their email and no password is set. + In this case, the user gets confirmed, logged in, and all tokens - + including session ones - are expired. In theory, no other tokens + exist but we delete all of them for best security practices. + + 3. The user has not confirmed their email but a password is set. + This cannot happen in the default implementation but may be the + source of security pitfalls. See the "Mixing magic link and password registration" section of + `mix help phx.gen.auth`. + """ + def login_user_by_magic_link(token) do + {:ok, query} = UserToken.verify_magic_link_token_query(token) + + case Repo.one(query) do + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password + {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> + raise """ + magic link log in is not allowed for unconfirmed users with a password set! + + This cannot happen with the default implementation, which indicates that you + might have adapted the code to a different use case. Please make sure to read the + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + """ + + {%User{confirmed_at: nil} = user, _token} -> + user + |> User.confirm_changeset() + |> update_user_and_delete_all_tokens() + + {user, token} -> + Repo.delete!(token) + {:ok, {user, []}} + + nil -> + {:error, :not_found} + end + end + + @doc ~S""" + Delivers the update email instructions to the given user. + + ## Examples + + iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}")) + {:ok, %{to: ..., body: ...}} + + """ + def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) + when is_function(update_email_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") + + Repo.insert!(user_token) + UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + end + + @doc """ + Delivers the magic link login instructions to the given user. + """ + def deliver_login_instructions(%User{} = user, magic_link_url_fun) + when is_function(magic_link_url_fun, 1) do + {encoded_token, user_token} = UserToken.build_email_token(user, "login") + Repo.insert!(user_token) + UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"])) + :ok + end + + ## Token helper + + defp update_user_and_delete_all_tokens(changeset) do + Repo.transact(fn -> + with {:ok, user} <- Repo.update(changeset) do + tokens_to_expire = Repo.all_by(UserToken, user_id: user.id) + + Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) + + {:ok, {user, tokens_to_expire}} + end + end) + end +end diff --git a/app/lib/firehose/accounts/scope.ex b/app/lib/firehose/accounts/scope.ex new file mode 100644 index 0000000..7a560f0 --- /dev/null +++ b/app/lib/firehose/accounts/scope.ex @@ -0,0 +1,33 @@ +defmodule Firehose.Accounts.Scope do + @moduledoc """ + Defines the scope of the caller to be used throughout the app. + + The `Firehose.Accounts.Scope` allows public interfaces to receive + information about the caller, such as if the call is initiated from an + end-user, and if so, which user. Additionally, such a scope can carry fields + such as "super user" or other privileges for use as authorization, or to + ensure specific code paths can only be access for a given scope. + + It is useful for logging as well as for scoping pubsub subscriptions and + broadcasts when a caller subscribes to an interface or performs a particular + action. + + Feel free to extend the fields on this struct to fit the needs of + growing application requirements. + """ + + alias Firehose.Accounts.User + + defstruct user: nil + + @doc """ + Creates a scope for the given user. + + Returns nil if no user is given. + """ + def for_user(%User{} = user) do + %__MODULE__{user: user} + end + + def for_user(nil), do: nil +end diff --git a/app/lib/firehose/accounts/user.ex b/app/lib/firehose/accounts/user.ex new file mode 100644 index 0000000..458f208 --- /dev/null +++ b/app/lib/firehose/accounts/user.ex @@ -0,0 +1,132 @@ +defmodule Firehose.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :utc_datetime + field :authenticated_at, :utc_datetime, virtual: true + + timestamps(type: :utc_datetime) + end + + @doc """ + A user changeset for registering or changing the email. + + It requires the email to change otherwise an error is added. + + ## Options + + * `:validate_unique` - Set to false if you don't want to validate the + uniqueness of the email, useful when displaying live validations. + Defaults to `true`. + """ + def email_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:email]) + |> validate_email(opts) + end + + defp validate_email(changeset, opts) do + changeset = + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) + + if Keyword.get(opts, :validate_unique, true) do + changeset + |> unsafe_validate_unique(:email, Firehose.Repo) + |> unique_constraint(:email) + |> validate_email_changed() + else + changeset + end + end + + defp validate_email_changed(changeset) do + if get_field(changeset, :email) && get_change(changeset, :email) == nil do + add_error(changeset, :email, "did not change") + else + changeset + end + end + + @doc """ + A user changeset for changing the password. + + It is important to validate the length of the password, as long passwords may + be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # Examples of additional password validation: + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that + # would keep the database transaction open longer and hurt performance. + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = DateTime.utc_now(:second) + change(user, confirmed_at: now) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Firehose.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end +end diff --git a/app/lib/firehose/accounts/user_notifier.ex b/app/lib/firehose/accounts/user_notifier.ex new file mode 100644 index 0000000..c6d2c59 --- /dev/null +++ b/app/lib/firehose/accounts/user_notifier.ex @@ -0,0 +1,84 @@ +defmodule Firehose.Accounts.UserNotifier do + import Swoosh.Email + + alias Firehose.Mailer + alias Firehose.Accounts.User + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Firehose", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to log in with a magic link. + """ + def deliver_login_instructions(user, url) do + case user do + %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url) + _ -> deliver_magic_link_instructions(user, url) + end + end + + defp deliver_magic_link_instructions(user, url) do + deliver(user.email, "Log in instructions", """ + + ============================== + + Hi #{user.email}, + + You can log into your account by visiting the URL below: + + #{url} + + If you didn't request this email, please ignore this. + + ============================== + """) + end + + defp deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end +end diff --git a/app/lib/firehose/accounts/user_token.ex b/app/lib/firehose/accounts/user_token.ex new file mode 100644 index 0000000..e95f5a5 --- /dev/null +++ b/app/lib/firehose/accounts/user_token.ex @@ -0,0 +1,156 @@ +defmodule Firehose.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Firehose.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the magic link token expiry short, + # since someone with access to the email may take over the account. + @magic_link_validity_in_minutes 15 + @change_email_validity_in_days 7 + @session_validity_in_days 14 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + field :authenticated_at, :utc_datetime + belongs_to :user, Firehose.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix's default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + dt = user.authenticated_at || DateTime.utc_now(:second) + {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any, along with the token's creation time. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in by_token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at} + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + If found, the query returns a tuple of the form `{user, token}`. + + The given token is valid if it matches its hashed counterpart in the + database. This function also checks whether the token has expired. The context + of a magic link token is always "login". + """ + def verify_magic_link_token_query(token) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, "login"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), + where: token.sent_to == user.email, + select: {user, token} + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user_token found by the token, if any. + + This is used to validate requests to change the user + email. + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + defp by_token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end +end diff --git a/app/lib/firehose_web/components/layouts.ex b/app/lib/firehose_web/components/layouts.ex index 727f358..6e33e2c 100644 --- a/app/lib/firehose_web/components/layouts.ex +++ b/app/lib/firehose_web/components/layouts.ex @@ -9,6 +9,7 @@ defmodule FirehoseWeb.Layouts do # The default root.html.heex file contains the HTML # skeleton of your application, namely HTML headers # and other static content. + embed_templates "layouts/*" @doc """ diff --git a/app/lib/firehose_web/controllers/blog_controller.ex b/app/lib/firehose_web/controllers/blog_controller.ex index 8e243e1..60ab50f 100644 --- a/app/lib/firehose_web/controllers/blog_controller.ex +++ b/app/lib/firehose_web/controllers/blog_controller.ex @@ -22,11 +22,14 @@ defmodule FirehoseWeb.BlogController do def show(conn, %{"slug" => slug}) do blog = conn.assigns.blog post = blog.get_post!(slug) + visibility = Blogex.Post.visibility(post) render(conn, :show, page_title: post.title, post: post, - base_path: blog.base_path() + base_path: blog.base_path(), + visibility: visibility, + authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user) ) end diff --git a/app/lib/firehose_web/controllers/blog_html/show.html.heex b/app/lib/firehose_web/controllers/blog_html/show.html.heex index 28cb722..8f4313b 100644 --- a/app/lib/firehose_web/controllers/blog_html/show.html.heex +++ b/app/lib/firehose_web/controllers/blog_html/show.html.heex @@ -1,4 +1,23 @@
← Back to posts + + <%= if @authenticated and @visibility == :draft do %> +
+ Draft — not published +
+ <% end %> + + <%= if @authenticated and @visibility == :scheduled do %> +
+ This post is scheduled for {Calendar.strftime(@post.date, "%B %d, %Y")} +
+ <% end %> + <.post_show post={@post} base_path={@base_path} />
diff --git a/app/lib/firehose_web/controllers/user_registration_controller.ex b/app/lib/firehose_web/controllers/user_registration_controller.ex new file mode 100644 index 0000000..3c4245e --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_controller.ex @@ -0,0 +1,44 @@ +defmodule FirehoseWeb.UserRegistrationController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias Firehose.Accounts.User + + def new(conn, _params) do + changeset = Accounts.change_user_email(%User{}) + render(conn, :new, changeset: changeset) + end + + def create(conn, %{"user" => user_params}) do + allowed_email = Application.get_env(:firehose, :allowed_registration_email) + + if allowed_email == nil or user_params["email"] != allowed_email do + changeset = + %User{} + |> Accounts.change_user_email(user_params) + |> Ecto.Changeset.add_error(:email, "registration is invite only.") + |> Map.put(:action, :validate) + + render(conn, :new, changeset: changeset) + else + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + conn + |> put_flash( + :info, + "An email was sent to #{user.email}, please access it to confirm your account." + ) + |> redirect(to: ~p"/users/log-in") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + end +end diff --git a/app/lib/firehose_web/controllers/user_registration_html.ex b/app/lib/firehose_web/controllers/user_registration_html.ex new file mode 100644 index 0000000..4835923 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_html.ex @@ -0,0 +1,5 @@ +defmodule FirehoseWeb.UserRegistrationHTML do + use FirehoseWeb, :html + + embed_templates "user_registration_html/*" +end diff --git a/app/lib/firehose_web/controllers/user_registration_html/new.html.heex b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex new file mode 100644 index 0000000..d6444a1 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_registration_html/new.html.heex @@ -0,0 +1,30 @@ +
+
+ <.header> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + +
+ + <.form :let={f} for={@changeset} action={~p"/users/register"}> + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + + <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> + Create an account + + +
diff --git a/app/lib/firehose_web/controllers/user_session_controller.ex b/app/lib/firehose_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..acb48e3 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_controller.ex @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.UserSessionController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias FirehoseWeb.UserAuth + + def new(conn, _params) do + email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) + form = Phoenix.Component.to_form(%{"email" => email}, as: "user") + + render(conn, :new, form: form) + end + + # magic link login + def create(conn, %{"user" => %{"token" => token} = user_params} = params) do + info = + case params do + %{"_action" => "confirmed"} -> "User confirmed successfully." + _ -> "Welcome back!" + end + + case Accounts.login_user_by_magic_link(token) do + {:ok, {user, _expired_tokens}} -> + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + + {:error, :not_found} -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user")) + end + end + + # email + password login + def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, "Welcome back!") + |> UserAuth.log_in_user(user, user_params) + else + form = Phoenix.Component.to_form(user_params, as: "user") + + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> render(:new, form: form) + end + end + + # magic link request + def create(conn, %{"user" => %{"email" => email}}) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + conn + |> put_flash(:info, info) + |> redirect(to: ~p"/users/log-in") + end + + def confirm(conn, %{"token" => token}) do + if user = Accounts.get_user_by_magic_link_token(token) do + form = Phoenix.Component.to_form(%{"token" => token}, as: "user") + + conn + |> assign(:user, user) + |> assign(:form, form) + |> render(:confirm) + else + conn + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> redirect(to: ~p"/users/log-in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/app/lib/firehose_web/controllers/user_session_html.ex b/app/lib/firehose_web/controllers/user_session_html.ex new file mode 100644 index 0000000..9668513 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html.ex @@ -0,0 +1,9 @@ +defmodule FirehoseWeb.UserSessionHTML do + use FirehoseWeb, :html + + embed_templates "user_session_html/*" + + defp local_mail_adapter? do + Application.get_env(:firehose, Firehose.Mailer)[:adapter] == Swoosh.Adapters.Local + end +end diff --git a/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex new file mode 100644 index 0000000..7597e79 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html/confirm.html.heex @@ -0,0 +1,57 @@ +
+
+ <.header>Welcome {@user.email} +
+ + <.form + :if={!@user.confirmed_at} + for={@form} + id="confirmation_form" + action={~p"/users/log-in?_action=confirmed"} + phx-mounted={JS.focus_first()} + > + + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Confirming..." + class="btn btn-primary w-full" + > + Confirm and stay logged in + + <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> + Confirm and log in only this time + + + + <.form + :if={@user.confirmed_at} + for={@form} + id="login_form" + action={~p"/users/log-in"} + phx-mounted={JS.focus_first()} + > + + <%= if @current_scope do %> + <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> + Log in + + <% else %> + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Logging in..." + class="btn btn-primary w-full" + > + Keep me logged in on this device + + <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> + Log me in only this time + + <% end %> + + +

+ Tip: If you prefer passwords, you can enable them in the user settings. +

+
diff --git a/app/lib/firehose_web/controllers/user_session_html/new.html.heex b/app/lib/firehose_web/controllers/user_session_html/new.html.heex new file mode 100644 index 0000000..92ce5cd --- /dev/null +++ b/app/lib/firehose_web/controllers/user_session_html/new.html.heex @@ -0,0 +1,71 @@ +
+
+ <.header> +

Log in

+ <:subtitle> + <%= if @current_scope do %> + You need to reauthenticate to perform sensitive actions on your account. + <% else %> + Don't have an account? <.link + navigate={~p"/users/register"} + class="font-semibold text-brand hover:underline" + phx-no-format + >Sign up for an account now. + <% end %> + + +
+ +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
+
+ + <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + phx-mounted={JS.focus()} + /> + <.button class="btn btn-primary w-full"> + Log in with email + + + +
or
+ + <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}> + <.input + readonly={!!@current_scope} + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + <.input + field={f[:password]} + type="password" + label="Password" + autocomplete="current-password" + spellcheck="false" + /> + <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> + Log in and stay logged in + + <.button class="btn btn-primary btn-soft w-full mt-2"> + Log in only this time + + +
diff --git a/app/lib/firehose_web/controllers/user_settings_controller.ex b/app/lib/firehose_web/controllers/user_settings_controller.ex new file mode 100644 index 0000000..224f538 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_controller.ex @@ -0,0 +1,77 @@ +defmodule FirehoseWeb.UserSettingsController do + use FirehoseWeb, :controller + + alias Firehose.Accounts + alias FirehoseWeb.UserAuth + + import FirehoseWeb.UserAuth, only: [require_sudo_mode: 2] + + plug :require_sudo_mode + plug :assign_email_and_password_changesets + + def edit(conn, _params) do + render(conn, :edit) + end + + def update(conn, %{"action" => "update_email"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.change_user_email(user, user_params) do + %{valid?: true} = changeset -> + Accounts.deliver_user_update_email_instructions( + Ecto.Changeset.apply_action!(changeset, :insert), + user.email, + &url(~p"/users/settings/confirm-email/#{&1}") + ) + + conn + |> put_flash( + :info, + "A link to confirm your email change has been sent to the new address." + ) + |> redirect(to: ~p"/users/settings") + + changeset -> + render(conn, :edit, email_changeset: %{changeset | action: :insert}) + end + end + + def update(conn, %{"action" => "update_password"} = params) do + %{"user" => user_params} = params + user = conn.assigns.current_scope.user + + case Accounts.update_user_password(user, user_params) do + {:ok, {user, _}} -> + conn + |> put_flash(:info, "Password updated successfully.") + |> put_session(:user_return_to, ~p"/users/settings") + |> UserAuth.log_in_user(user) + + {:error, changeset} -> + render(conn, :edit, password_changeset: changeset) + end + end + + def confirm_email(conn, %{"token" => token}) do + case Accounts.update_user_email(conn.assigns.current_scope.user, token) do + {:ok, _user} -> + conn + |> put_flash(:info, "Email changed successfully.") + |> redirect(to: ~p"/users/settings") + + {:error, _} -> + conn + |> put_flash(:error, "Email change link is invalid or it has expired.") + |> redirect(to: ~p"/users/settings") + end + end + + defp assign_email_and_password_changesets(conn, _opts) do + user = conn.assigns.current_scope.user + + conn + |> assign(:email_changeset, Accounts.change_user_email(user)) + |> assign(:password_changeset, Accounts.change_user_password(user)) + end +end diff --git a/app/lib/firehose_web/controllers/user_settings_html.ex b/app/lib/firehose_web/controllers/user_settings_html.ex new file mode 100644 index 0000000..e8522a5 --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_html.ex @@ -0,0 +1,5 @@ +defmodule FirehoseWeb.UserSettingsHTML do + use FirehoseWeb, :html + + embed_templates "user_settings_html/*" +end diff --git a/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex b/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex new file mode 100644 index 0000000..988ae4d --- /dev/null +++ b/app/lib/firehose_web/controllers/user_settings_html/edit.html.heex @@ -0,0 +1,47 @@ +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ +<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email"> + + + <.input + field={f[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> + + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ +<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password"> + + + <.input + field={f[:password]} + type="password" + label="New password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.input + field={f[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.button variant="primary" phx-disable-with="Changing..."> + Save Password + + diff --git a/app/lib/firehose_web/live/editor_dashboard_live.ex b/app/lib/firehose_web/live/editor_dashboard_live.ex new file mode 100644 index 0000000..48f0dd9 --- /dev/null +++ b/app/lib/firehose_web/live/editor_dashboard_live.ex @@ -0,0 +1,117 @@ +defmodule FirehoseWeb.EditorDashboardLive do + use FirehoseWeb, :live_view + + alias Blogex.Post + + @impl true + def mount(_params, _session, socket) do + all_posts = Blogex.Registry.all_posts_unfiltered() + + drafts = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :draft)) + |> Enum.sort_by(& &1.date, {:desc, Date}) + + scheduled = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :scheduled)) + |> Enum.sort_by(& &1.date, {:asc, Date}) + + {:ok, + socket + |> assign(:page_title, "Editor Dashboard") + |> assign(:drafts, drafts) + |> assign(:scheduled, scheduled) + |> assign(:active_tab, :drafts)} + end + + @impl true + def render(assigns) do + ~H""" +
+

Dashboard

+ +
+ + +
+ +
+
No drafts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · + Draft +
+
+
+
+
+ +
+
No scheduled posts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · + + {Post.days_until_live(post)} days until live + +
+
+
+
+
+
+ """ + end + + @impl true + def handle_event("switch_tab", %{"tab" => tab}, socket) do + {:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))} + end + + defp post_path(post) do + blog = Blogex.Registry.get_blog!(post.blog) + "#{blog.base_path()}/#{post.id}" + end +end diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex index 2a1e5ef..ff96fd4 100644 --- a/app/lib/firehose_web/router.ex +++ b/app/lib/firehose_web/router.ex @@ -1,6 +1,8 @@ defmodule FirehoseWeb.Router do use FirehoseWeb, :router + import FirehoseWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -9,6 +11,7 @@ defmodule FirehoseWeb.Router do plug :put_layout, html: {FirehoseWeb.Layouts, :app} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_scope_for_user end pipeline :api do @@ -51,4 +54,35 @@ defmodule FirehoseWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", FirehoseWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + get "/users/register", UserRegistrationController, :new + post "/users/register", UserRegistrationController, :create + end + + scope "/", FirehoseWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :authenticated_user, + on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email + end + + scope "/", FirehoseWeb do + pipe_through [:browser] + + get "/users/log-in", UserSessionController, :new + get "/users/log-in/:token", UserSessionController, :confirm + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete + end end diff --git a/app/lib/firehose_web/user_auth.ex b/app/lib/firehose_web/user_auth.ex new file mode 100644 index 0000000..96bdfb4 --- /dev/null +++ b/app/lib/firehose_web/user_auth.ex @@ -0,0 +1,256 @@ +defmodule FirehoseWeb.UserAuth do + use FirehoseWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + + # Make the remember me cookie valid for 14 days. This should match + # the session validity setting in UserToken. + @max_cookie_age_in_days 14 + @remember_me_cookie "_firehose_web_user_remember_me" + @remember_me_options [ + sign: true, + max_age: @max_cookie_age_in_days * 24 * 60 * 60, + same_site: "Lax" + ] + + # How old the session token should be before a new one is issued. When a request is made + # with a session token older than this value, then a new session token will be created + # and the session and remember-me cookies (if set) will be updated with the new token. + # Lowering this value will result in more tokens being created by active users. Increasing + # it will result in less time before a session token expires for a user to get issued a new + # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable + # the reissuing of tokens completely. + @session_reissue_age_in_days 7 + + @doc """ + Logs the user in. + + Redirects to the session's `:user_return_to` path + or falls back to the `signed_in_path/1`. + """ + def log_in_user(conn, user, params \\ %{}) do + user_return_to = get_session(conn, :user_return_to) + + conn + |> create_or_extend_session(user, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + FirehoseWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session(nil) + |> delete_resp_cookie(@remember_me_cookie, @remember_me_options) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session and remember me token. + + Will reissue the session token if it is older than the configured age. + """ + def fetch_current_scope_for_user(conn, _opts) do + with {token, conn} <- ensure_user_token(conn), + {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do + conn + |> assign(:current_scope, Scope.for_user(user)) + |> maybe_reissue_user_session_token(user, token_inserted_at) + else + nil -> assign(conn, :current_scope, Scope.for_user(nil)) + end + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)} + else + nil + end + end + end + + # Reissue the session token if it is older than the configured reissue age. + defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do + token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day) + + if token_age >= @session_reissue_age_in_days do + create_or_extend_session(conn, user, %{}) + else + conn + end + end + + # This function is the one responsible for creating session tokens + # and storing them safely in the session and cookies. It may be called + # either when logging in, during sudo mode, or to renew a session which + # will soon expire. + # + # When the session is created, rather than extended, the renew_session + # function will clear the session to avoid fixation attacks. See the + # renew_session function to customize this behaviour. + defp create_or_extend_session(conn, user, params) do + token = Accounts.generate_user_session_token(user) + remember_me = get_session(conn, :user_remember_me) + + conn + |> renew_session(user) + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params, remember_me) + end + + # Do not renew session if the user is already logged in + # to prevent CSRF errors or data being lost in tabs that are still open + defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn, _user) do + # delete_csrf_token() + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn, _user) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, token, _params, true), + do: write_remember_me_cookie(conn, token) + + defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn + + defp write_remember_me_cookie(conn, token) do + conn + |> put_session(:user_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) + end + + defp put_token_in_session(conn, token) do + put_session(conn, :user_token, token) + end + + @doc """ + Plug for routes that require sudo mode. + """ + def require_sudo_mode(conn, _opts) do + if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do + conn + else + conn + |> put_flash(:error, "You must re-authenticate to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + @doc """ + Plug for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns.current_scope do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + defp signed_in_path(_conn), do: ~p"/" + + @doc """ + Plug for routes that require the user to be authenticated. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns.current_scope && conn.assigns.current_scope.user do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + @doc """ + LiveView on_mount callback that ensures the user is authenticated. + + Used in `live_session` blocks in the router: + + live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + """ + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_scope(socket, session) + + if socket.assigns.current_scope && socket.assigns.current_scope.user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + defp mount_current_scope(socket, session) do + Phoenix.Component.assign_new(socket, :current_scope, fn -> + if token = session["user_token"] do + case Accounts.get_user_by_session_token(token) do + {user, _token_inserted_at} -> Scope.for_user(user) + nil -> Scope.for_user(nil) + end + else + Scope.for_user(nil) + end + end) + end +end diff --git a/app/mix.exs b/app/mix.exs index f50949a..f6ad7a3 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -41,6 +41,7 @@ defmodule Firehose.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.8.1"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.13"}, diff --git a/app/mix.lock b/app/mix.lock index efbc8af..b58e1bc 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,7 +1,9 @@ %{ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [: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", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "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"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, diff --git a/app/priv/blog/engineering/2026/03-24-blog-triage.md b/app/priv/blog/engineering/2026/03-24-blog-triage.md index d02e2f0..5e1bc11 100644 --- a/app/priv/blog/engineering/2026/03-24-blog-triage.md +++ b/app/priv/blog/engineering/2026/03-24-blog-triage.md @@ -9,17 +9,17 @@ I made a skill for a coding agent to help me get more of my draft blog posts over the line. I enjoy writing, and am somewhat fluent in it. Publishing that writing is more hit and miss, however. I often lose energy just before a piece is finished enough. I want to publish more often, and need to form a more effective habit for it. -# What did I get out if it? +# What did I get out if it? -I got a working agent 'skill' in an hour or so. I like the QWEN models for their no-bullshit approach to feedback. As it turns out, I have about 60 pages with the 'Candidate Blogpost' tag in my notes, but most of them are not more than an idea. Only some of them have enough detail to turn into a post. I am going to keep this around, prune my candidate blogposts, and add my recent clippings to the mix. +I got a working agent 'skill' in an hour or so. I like the QWEN models for their no-bullshit approach to feedback. As it turns out, I have about 60 pages with the 'Candidate Blogpost' tag in my notes, but most of them are not more than an idea. Only some of them have enough detail to turn into a post. I am going to keep this around, prune my candidate blogposts, and add my recent clippings to the mix. Quite a few of my candidates were 'just links' according to the model, but as I am inspired by [Simon Willison](https://www.simonwillison.net), there is value in sharing links with a brief description on why I think they are relevant. Probably in a different category. -# How did I develop the skill? +# How did I develop the skill? I was inspired by two writings: -- Jurgen De Smet asking [how do you write long form articles?](https://www.linkedin.com/posts/jurgendesmet_this-is-how-i-write-long-form-articles-these-share-7441394036222935040-JHmv). +- Jurgen De Smet asking [how do you write long form articles?](https://www.linkedin.com/posts/jurgendesmet_this-is-how-i-write-long-form-articles-these-share-7441394036222935040-JHmv). - Chris Parsons suggested to [brief an agent for daily tasks](https://www.chrismdp.com/stop-prompting-start-briefing/), and use the _backbriefing_ loop from "The Art of Action" to improve them. I like "The Art of Action" - detailed, yet practical. So I had a chat with a frontier model to develop a skill for a local model to surface notes that are almost finished, with some suggestions to get them over the line. @@ -31,14 +31,14 @@ This was my initial prompt. Full chat transcript in the Further Reading section. What I found interesting was that, maybe because I mentioned the links were in an sqlite database, claude desktop spontaneously suggested to create a bash script as part of the skill. I used to have a meta-skill to separate the deterministic parts of agent skills into scripts, but that does not seem to be necessary anymore. I prune my agent setups continuously, only keeping what is needed. -# Tradeoffs +# Tradeoffs -Initially I planned to run this as a scheduled job, but from the development chat it emerged that backbriefing (improving the skill as we run it daily) would not work if it runs scheduled. +Initially I planned to run this as a scheduled job, but from the development chat it emerged that backbriefing (improving the skill as we run it daily) would not work if it runs scheduled. I chose a local coding agent with a local model, because I don't want to share my personal notes with a cloud service, and I thought that a smaller model would be more than powerful enough. -## Further reading +## Further reading https://claude.ai/share/be0184d9-f2bf-41ba-b2e3-235fe9daf9fd - initial chat do develop the skill diff --git a/app/priv/blog/engineering/2099/01-01-future-test-post.md b/app/priv/blog/engineering/2099/01-01-future-test-post.md new file mode 100644 index 0000000..792be17 --- /dev/null +++ b/app/priv/blog/engineering/2099/01-01-future-test-post.md @@ -0,0 +1,8 @@ +%{ + title: "Future Test Post", + author: "Test Author", + tags: ~w(test), + description: "A post scheduled for the future" +} +--- +This is a future test post. diff --git a/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md b/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md new file mode 100644 index 0000000..0c34971 --- /dev/null +++ b/app/priv/blog/release-notes/2026/04-01-scheduled-publishing.md @@ -0,0 +1,72 @@ +%{ + title: "Scheduled Publishing & Author Dashboard", + author: "Willem van den Ende", + tags: ~w(release features), + description: "Future-dated posts stay hidden until their publish date, authors get a dashboard to track drafts and scheduled content, and registration is locked down to invited emails only." +} +--- + +Posts in Firehose are markdown files with a date in the filename. Until now, every published post was immediately visible. That changes today: posts with a future date are now hidden from public views until their date arrives. + +This was built in a single session using an agentic dev team -- 12 issues tracked in beads, executed in three parallel phases, producing 232 tests across the blogex library and Phoenix app. + +## What changed + +### Future-dated posts are hidden from public views + +The blog index, tag pages, RSS feeds, and Atom feeds now filter out posts where the date is after today. If you schedule a post for next Tuesday, readers won't see it until then. + +![Blog index showing only past-dated posts](/images/scheduled-publishing/scheduled-blog-index.png) + +But here's the key design choice: **direct URL access still works**. If you know the slug, you can view the post. This lets authors share preview links with reviewers before the publish date. + +![Future post accessible by direct URL](/images/scheduled-publishing/scheduled-direct-access.png) + +### Status banners for authors + +When you're logged in, draft and scheduled posts show a status banner so you always know what state a post is in. Unauthenticated visitors see nothing -- no clue the post isn't "live" yet. + +**Scheduled posts** show a blue banner with the target date: + +![Scheduled banner showing target publish date](/images/scheduled-publishing/scheduled-banner-future.png) + +**Draft posts** (unpublished) show an amber banner: + +![Draft banner on unpublished post](/images/scheduled-publishing/scheduled-banner-draft.png) + +### Editor dashboard + +A new LiveView at `/editor/dashboard` gives authors a unified view of all non-live content across every blog. Two tabs: drafts and scheduled posts. Scheduled posts show a "days until live" countdown. + +![Dashboard drafts tab](/images/scheduled-publishing/scheduled-dashboard.png) + +![Dashboard scheduled tab with countdown](/images/scheduled-publishing/scheduled-dashboard-scheduled.png) + +The dashboard requires authentication -- unauthenticated users are redirected to the login page. + +### Authentication and registration gating + +We added `mix phx.gen.auth` for session-based authentication with magic links and password login. Login and registration pages are accessible by direct URL only -- they're intentionally not linked from the public navigation. + +Registration is gated to a single email via the `ALLOWED_REGISTRATION_EMAIL` environment variable. Anyone else gets a polite rejection: + +![Registration rejected for non-matching email](/images/scheduled-publishing/scheduled-registration-rejected.png) + +When the environment variable isn't set, registration is disabled entirely. A demo user (`demo@example.com`) is seeded in dev for local testing. + +## How it was built + +The feature was planned as an [Allium specification](https://github.com/your-org/allium) with surfaces, rules, and domain entities, then broken into 12 beads (issues) across three phases: + +1. **Scheduled posts** (5 beads): date filtering in blogex, unfiltered direct access, feed/router verification +2. **Authentication** (3 beads): phx.gen.auth scaffolding, registration gating, dev seed +3. **Dashboard** (4 beads): post visibility helpers, unfiltered registry access, LiveView dashboard, status banners + +All 12 beads were executed with parallel agentic workers in isolated git worktrees, then merged and integrated on main. The demo caught one bug (auth check using `current_user` instead of `current_scope`) which was fixed before this post. + +## By the numbers + +- **232 tests** passing (89 blogex + 143 Phoenix app) +- **12 beads** planned, executed, and closed +- **3 phases** run with parallel workers +- **0 compiler warnings** diff --git a/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs b/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs new file mode 100644 index 0000000..cd9c39f --- /dev/null +++ b/app/priv/repo/migrations/20260401201722_create_users_auth_tables.exs @@ -0,0 +1,30 @@ +defmodule Firehose.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + add :authenticated_at, :utc_datetime + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/app/priv/repo/seeds.exs b/app/priv/repo/seeds.exs index 369044f..36dbe87 100644 --- a/app/priv/repo/seeds.exs +++ b/app/priv/repo/seeds.exs @@ -9,3 +9,13 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +if Mix.env() == :dev do + alias Firehose.Accounts + + # Create demo user if not already present + unless Accounts.get_user_by_email("demo@example.com") do + {:ok, user} = Accounts.register_user(%{email: "demo@example.com"}) + {:ok, {_user, _tokens}} = Accounts.update_user_password(user, %{password: "password123!"}) + end +end diff --git a/app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png b/app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png new file mode 100644 index 0000000..6bd2488 Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-banner-draft.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-banner-future.png b/app/priv/static/images/scheduled-publishing/scheduled-banner-future.png new file mode 100644 index 0000000..3f5f203 Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-banner-future.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-blog-index.png b/app/priv/static/images/scheduled-publishing/scheduled-blog-index.png new file mode 100644 index 0000000..ad0a37d Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-blog-index.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-dashboard-scheduled.png b/app/priv/static/images/scheduled-publishing/scheduled-dashboard-scheduled.png new file mode 100644 index 0000000..6426a2d Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-dashboard-scheduled.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-dashboard.png b/app/priv/static/images/scheduled-publishing/scheduled-dashboard.png new file mode 100644 index 0000000..68401f0 Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-dashboard.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-direct-access.png b/app/priv/static/images/scheduled-publishing/scheduled-direct-access.png new file mode 100644 index 0000000..9cc0fc5 Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-direct-access.png differ diff --git a/app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png b/app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png new file mode 100644 index 0000000..22d8947 Binary files /dev/null and b/app/priv/static/images/scheduled-publishing/scheduled-registration-rejected.png differ diff --git a/app/test/firehose/accounts_test.exs b/app/test/firehose/accounts_test.exs new file mode 100644 index 0000000..abb569e --- /dev/null +++ b/app/test/firehose/accounts_test.exs @@ -0,0 +1,397 @@ +defmodule Firehose.AccountsTest do + use Firehose.DataCase + + alias Firehose.Accounts + + import Firehose.AccountsFixtures + alias Firehose.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() |> set_password() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() |> set_password() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{email: ["can't be blank"]} = errors_on(changeset) + end + + test "validates email when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum values for email for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the uppercased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users without password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_nil(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "sudo_mode?/2" do + test "validates the authenticated_at time" do + now = DateTime.utc_now() + + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()}) + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)}) + refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)}) + + # minute override + refute Accounts.sudo_mode?( + %User{authenticated_at: DateTime.add(now, -11, :minute)}, + -10 + ) + + # not authenticated + refute Accounts.sudo_mode?(%User{}) + end + end + + describe "change_user_email/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = unconfirmed_user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token) + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + + assert Accounts.update_user_email(user, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password( + %User{}, + %{ + "password" => "new valid password" + }, + hash_password: false + ) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, {user, expired_tokens}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + assert expired_tokens == [] + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, {_, _}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + assert user_token.authenticated_at != nil + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + + test "duplicates the authenticated_at of given user in new token", %{user: user} do + user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)} + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.authenticated_at == user.authenticated_at + assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + assert session_user.authenticated_at != nil + assert token_inserted_at != nil + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + dt = ~N[2020-01-01 00:00:00] + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "get_user_by_magic_link_token/1" do + setup do + user = user_fixture() + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + %{user: user, token: encoded_token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_magic_link_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_magic_link_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_magic_link_token(token) + end + end + + describe "login_user_by_magic_link/1" do + test "confirms user and expires tokens" do + user = unconfirmed_user_fixture() + refute user.confirmed_at + {encoded_token, hashed_token} = generate_user_magic_link_token(user) + + assert {:ok, {user, [%{token: ^hashed_token}]}} = + Accounts.login_user_by_magic_link(encoded_token) + + assert user.confirmed_at + end + + test "returns user and (deleted) token for confirmed user" do + user = user_fixture() + assert user.confirmed_at + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token) + # one time use only + assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) + end + + test "raises when unconfirmed user has password set" do + user = unconfirmed_user_fixture() + {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + + assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> + Accounts.login_user_by_magic_link(encoded_token) + end + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_login_instructions/2" do + setup do + %{user: unconfirmed_user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "login" + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/app/test/firehose_web/controllers/blog_controller_test.exs b/app/test/firehose_web/controllers/blog_controller_test.exs new file mode 100644 index 0000000..ce8db37 --- /dev/null +++ b/app/test/firehose_web/controllers/blog_controller_test.exs @@ -0,0 +1,73 @@ +defmodule FirehoseWeb.BlogControllerTest do + use FirehoseWeb.ConnCase, async: false + + describe "GET /blog/:blog_id (index) - date filtering" do + test "does not show future-dated posts", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering") + html = html_response(conn, 200) + refute html =~ "Future Test Post" + end + end + + describe "GET /blog/:blog_id/:slug (show) - date filtering" do + test "still shows a future-dated post by slug", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/future-test-post") + assert html_response(conn, 200) =~ "Future Test Post" + end + end + + describe "GET /blog/:blog_id/tag/:tag - date filtering" do + test "excludes future-dated posts from tag page", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/tag/test") + html = html_response(conn, 200) + refute html =~ "Future Test Post" + end + end + + describe "GET /blog/:blog_id/:slug - status banners" do + setup :register_and_log_in_user + + test "authenticated user sees draft banner on draft post", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/hello-world") + + assert html_response(conn, 200) =~ "Draft" + assert conn.resp_body =~ "not published" + end + + test "authenticated user sees scheduled banner on future post", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/future-test-post") + + response = html_response(conn, 200) + assert response =~ "scheduled for" + assert response =~ "January 01, 2099" + end + + test "authenticated user sees no banner on live post", %{conn: conn} do + conn = get(conn, ~p"/blog/engineering/why-firehose") + + response = html_response(conn, 200) + refute response =~ "Draft" + refute response =~ "scheduled for" + end + end + + describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do + test "unauthenticated user sees no banner on draft post", %{conn: conn} do + response = + conn + |> get(~p"/blog/engineering/hello-world") + |> html_response(200) + + refute response =~ "post-status-banner" + end + + test "unauthenticated user sees no banner on future post", %{conn: conn} do + response = + conn + |> get(~p"/blog/engineering/future-test-post") + |> html_response(200) + + refute response =~ "post-status-banner" + end + end +end diff --git a/app/test/firehose_web/controllers/user_registration_controller_test.exs b/app/test/firehose_web/controllers/user_registration_controller_test.exs new file mode 100644 index 0000000..b26a34c --- /dev/null +++ b/app/test/firehose_web/controllers/user_registration_controller_test.exs @@ -0,0 +1,80 @@ +defmodule FirehoseWeb.UserRegistrationControllerTest do + use FirehoseWeb.ConnCase, async: true + + import Firehose.AccountsFixtures + + describe "GET /users/register" do + test "renders registration page", %{conn: conn} do + conn = get(conn, ~p"/users/register") + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ ~p"/users/log-in" + assert response =~ ~p"/users/register" + end + + test "redirects if already logged in", %{conn: conn} do + conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register") + + assert redirected_to(conn) == ~p"/" + end + end + + describe "POST /users/register" do + @tag :capture_log + test "creates account but does not log in", %{conn: conn} do + email = unique_user_email() + Application.put_env(:firehose, :allowed_registration_email, email) + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = + post(conn, ~p"/users/register", %{ + "user" => valid_user_attributes(email: email) + }) + + refute get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/users/log-in" + + assert conn.assigns.flash["info"] =~ + ~r/An email was sent to .*, please access it to confirm your account/ + end + + test "render errors for invalid data", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "with spaces") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = + post(conn, ~p"/users/register", %{ + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Register" + assert response =~ "must have the @ sign and no spaces" + end + end + + describe "POST /users/register with email gating" do + test "succeeds when email matches ALLOWED_REGISTRATION_EMAIL", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "allowed@example.com"}}) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "email was sent" + end + + test "fails with invite-only message when email doesn't match", %{conn: conn} do + Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com") + on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "other@example.com"}}) + assert html_response(conn, 200) =~ "registration is invite only" + end + + test "fails with invite-only message when env var is unset", %{conn: conn} do + Application.delete_env(:firehose, :allowed_registration_email) + + conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "anyone@example.com"}}) + assert html_response(conn, 200) =~ "registration is invite only" + end + end +end diff --git a/app/test/firehose_web/controllers/user_session_controller_test.exs b/app/test/firehose_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..4a92555 --- /dev/null +++ b/app/test/firehose_web/controllers/user_session_controller_test.exs @@ -0,0 +1,199 @@ +defmodule FirehoseWeb.UserSessionControllerTest do + use FirehoseWeb.ConnCase, async: true + + import Firehose.AccountsFixtures + alias Firehose.Accounts + + setup do + %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()} + end + + describe "GET /users/log-in" do + test "renders login page", %{conn: conn} do + conn = get(conn, ~p"/users/log-in") + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ ~p"/users/register" + assert response =~ "Log in with email" + end + + test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do + html = + conn + |> log_in_user(user) + |> get(~p"/users/log-in") + |> html_response(200) + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" + + assert html =~ + ~s( + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + assert html_response(conn, 200) =~ "Confirm and stay logged in" + end + + test "renders login page for confirmed user", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + conn = get(conn, ~p"/users/log-in/#{token}") + html = html_response(conn, 200) + refute html =~ "Confirm my account" + assert html =~ "Keep me logged in on this device" + end + + test "raises error for invalid token", %{conn: conn} do + conn = get(conn, ~p"/users/log-in/invalid-token") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Magic link is invalid or it has expired." + end + end + + describe "POST /users/log-in - email and password" do + test "logs the user in", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_firehose_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + user = set_password(user) + + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "emits error message with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in?mode=password", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + response = html_response(conn, 200) + assert response =~ "Log in" + assert response =~ "Invalid email or password" + end + end + + describe "POST /users/log-in - magic link" do + test "sends magic link email when user exists", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Firehose.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login" + end + + test "logs the user in", %{conn: conn, user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + end + + test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + refute user.confirmed_at + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token}, + "_action" => "confirmed" + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." + + assert Accounts.get_user!(user.id).confirmed_at + end + + test "emits error message when magic link is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => "invalid"} + }) + + assert html_response(conn, 200) =~ "The link is invalid or it has expired." + end + end + + describe "DELETE /users/log-out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/app/test/firehose_web/controllers/user_settings_controller_test.exs b/app/test/firehose_web/controllers/user_settings_controller_test.exs new file mode 100644 index 0000000..d4476f8 --- /dev/null +++ b/app/test/firehose_web/controllers/user_settings_controller_test.exs @@ -0,0 +1,148 @@ +defmodule FirehoseWeb.UserSettingsControllerTest do + use FirehoseWeb.ConnCase, async: true + + alias Firehose.Accounts + import Firehose.AccountsFixtures + + setup :register_and_log_in_user + + describe "GET /users/settings" do + test "renders settings page", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + response = html_response(conn, 200) + assert response =~ "Settings" + end + + test "redirects if user is not logged in" do + conn = build_conn() + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + end + + @tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + test "redirects if user is not in sudo mode", %{conn: conn} do + conn = get(conn, ~p"/users/settings") + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "PUT /users/settings (change password form)" do + test "updates the user password and resets tokens", %{conn: conn, user: user} do + new_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + }) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not update password on invalid data", %{conn: conn} do + old_password_conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_password", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + response = html_response(old_password_conn, 200) + assert response =~ "Settings" + assert response =~ "should be at least 12 character(s)" + assert response =~ "does not match password" + + assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) + end + end + + describe "PUT /users/settings (change email form)" do + @tag :capture_log + test "updates the user email", %{conn: conn, user: user} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => unique_user_email()} + }) + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "A link to confirm your email" + + assert Accounts.get_user_by_email(user.email) + end + + test "does not update email on invalid data", %{conn: conn} do + conn = + put(conn, ~p"/users/settings", %{ + "action" => "update_email", + "user" => %{"email" => "with spaces"} + }) + + response = html_response(conn, 200) + assert response =~ "Settings" + assert response =~ "must have the @ sign and no spaces" + end + end + + describe "GET /users/settings/confirm-email/:token" do + setup %{user: user} do + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{token: token, email: email} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Email changed successfully" + + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + conn = get(conn, ~p"/users/settings/confirm-email/oops") + assert redirected_to(conn) == ~p"/users/settings" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Email change link is invalid or it has expired" + + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + conn = get(conn, ~p"/users/settings/confirm-email/#{token}") + assert redirected_to(conn) == ~p"/users/log-in" + end + end +end diff --git a/app/test/firehose_web/live/editor_dashboard_live_test.exs b/app/test/firehose_web/live/editor_dashboard_live_test.exs new file mode 100644 index 0000000..462dd9b --- /dev/null +++ b/app/test/firehose_web/live/editor_dashboard_live_test.exs @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.EditorDashboardLiveTest do + use FirehoseWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup do + posts = [ + %Blogex.Post{ + id: "live-post", + title: "Live Post", + author: "Test Author", + body: "

Body

", + description: "A live post", + date: ~D[2020-01-01], + published: true, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "draft-post", + title: "Draft Post", + author: "Test Author", + body: "

Body

", + description: "A draft post", + date: ~D[2026-03-12], + published: false, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "scheduled-post", + title: "Scheduled Post", + author: "Test Author", + body: "

Body

", + description: "A scheduled post", + date: ~D[2099-06-15], + published: true, + blog: :test_blog, + tags: ["future"] + } + ] + + {:ok, _} = + Firehose.Test.FakeBlog.start(posts, + blog_id: :test_blog, + title: "Test Blog", + base_path: "/blog/test" + ) + + Application.put_env(:blogex, :blogs, [Firehose.Test.FakeBlog]) + on_exit(fn -> Application.delete_env(:blogex, :blogs) end) + + :ok + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/editor/dashboard") + assert {:redirect, %{to: to}} = redirect + assert to =~ "/users/log-in" + end + end + + describe "authenticated" do + setup :register_and_log_in_user + + test "renders the dashboard", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Dashboard" + end + + test "shows draft posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Draft Post" + end + + test "shows scheduled posts with days until live", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Scheduled Post" + assert html =~ "days" + end + + test "does not show live posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + refute html =~ "Live Post" + end + end +end diff --git a/app/test/firehose_web/user_auth_test.exs b/app/test/firehose_web/user_auth_test.exs new file mode 100644 index 0000000..ba4d685 --- /dev/null +++ b/app/test/firehose_web/user_auth_test.exs @@ -0,0 +1,293 @@ +defmodule FirehoseWeb.UserAuthTest do + use FirehoseWeb.ConnCase, async: true + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + alias FirehoseWeb.UserAuth + + import Firehose.AccountsFixtures + + @remember_me_cookie "_firehose_web_user_remember_me" + @remember_me_cookie_max_age 60 * 60 * 24 * 14 + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "keeps session when re-authenticating", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + assert get_session(conn, :to_be_removed) + end + + test "clears session when user does not match when re-authenticating", %{ + conn: conn, + user: user + } do + other_user = user_fixture() + + conn = + conn + |> assign(:current_scope, Scope.for_user(other_user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + end + + test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + conn = + conn + |> recycle() + |> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base)) + |> fetch_cookies() + |> init_test_session(%{user_remember_me: true}) + + # the conn is already logged in and has the remember_me cookie set, + # now we log in again and even without explicitly setting remember_me, + # the cookie should be set again + conn = conn |> UserAuth.log_in_user(user, %{}) + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + assert get_session(conn, :user_remember_me) == true + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_scope_for_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + assert get_session(conn, :user_remember_me) + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_scope_for_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_scope + end + + test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + offset_user_token(token, -10, :day) + {user, _} = Accounts.get_user_by_session_token(token) + + conn = + conn + |> put_session(:user_token, token) + |> put_session(:user_remember_me, true) + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert new_token = get_session(conn, :user_token) + assert new_token != token + assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert new_signed_token != signed_token + assert max_age == @remember_me_cookie_max_age + end + end + + describe "require_sudo_mode/2" do + test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + refute conn.halted + refute conn.status + end + + test "redirects when authentication is too old", %{conn: conn, user: user} do + eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute) + user = %{user | authenticated_at: eleven_minutes_ago} + user_token = Accounts.generate_user_session_token(user) + {user, token_inserted_at} = Accounts.get_user_by_session_token(user_token) + assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt + + conn = + conn + |> fetch_flash() + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_sudo_mode([]) + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must re-authenticate to access this page." + end + end + + describe "redirect_if_user_is_authenticated/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.redirect_if_user_is_authenticated([]) + + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_authenticated_user([]) + + refute conn.halted + refute conn.status + end + end +end diff --git a/app/test/support/conn_case.ex b/app/test/support/conn_case.ex index 0d4b55a..4616a64 100644 --- a/app/test/support/conn_case.ex +++ b/app/test/support/conn_case.ex @@ -35,4 +35,45 @@ defmodule FirehoseWeb.ConnCase do Firehose.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn} = context) do + user = Firehose.AccountsFixtures.user_fixture() + scope = Firehose.Accounts.Scope.for_user(user) + + opts = + context + |> Map.take([:token_authenticated_at]) + |> Enum.into([]) + + %{conn: log_in_user(conn, user, opts), user: user, scope: scope} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user, opts \\ []) do + token = Firehose.Accounts.generate_user_session_token(user) + + maybe_set_token_authenticated_at(token, opts[:token_authenticated_at]) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end + + defp maybe_set_token_authenticated_at(_token, nil), do: nil + + defp maybe_set_token_authenticated_at(token, authenticated_at) do + Firehose.AccountsFixtures.override_token_authenticated_at(token, authenticated_at) + end end diff --git a/app/test/support/fake_blog.ex b/app/test/support/fake_blog.ex new file mode 100644 index 0000000..2515f95 --- /dev/null +++ b/app/test/support/fake_blog.ex @@ -0,0 +1,65 @@ +defmodule Firehose.Test.FakeBlog do + @moduledoc """ + A test double that implements the blog module interface, + backed by an Agent so tests can control the post data. + """ + + use Agent + + @defaults [ + blog_id: :test_blog, + title: "Test Blog", + description: "A blog for tests", + base_path: "/blog/test" + ] + + def start(posts \\ [], opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + state = %{ + posts: posts, + blog_id: opts[:blog_id], + title: opts[:title], + description: opts[:description], + base_path: opts[:base_path] + } + + case Agent.start(fn -> state end, name: __MODULE__) do + {:ok, pid} -> + {:ok, pid} + + {:error, {:already_started, pid}} -> + Agent.update(__MODULE__, fn _ -> state end) + {:ok, pid} + end + end + + defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key)) + + def blog_id, do: get(:blog_id) + def title, do: get(:title) + def description, do: get(:description) + def base_path, do: get(:base_path) + + def all_posts_unfiltered do + get(:posts) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def unfiltered_posts do + all_posts_unfiltered() + end + + def all_posts do + get(:posts) + |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end +end diff --git a/app/test/support/fixtures/accounts_fixtures.ex b/app/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..c722399 --- /dev/null +++ b/app/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,89 @@ +defmodule Firehose.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Firehose.Accounts` context. + """ + + import Ecto.Query + + alias Firehose.Accounts + alias Firehose.Accounts.Scope + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email() + }) + end + + def unconfirmed_user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Accounts.register_user() + + user + end + + def user_fixture(attrs \\ %{}) do + user = unconfirmed_user_fixture(attrs) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, {user, _expired_tokens}} = + Accounts.login_user_by_magic_link(token) + + user + end + + def user_scope_fixture do + user = user_fixture() + user_scope_fixture(user) + end + + def user_scope_fixture(user) do + Scope.for_user(user) + end + + def set_password(user) do + {:ok, {user, _expired_tokens}} = + Accounts.update_user_password(user, %{password: valid_user_password()}) + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end + + def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do + Firehose.Repo.update_all( + from(t in Accounts.UserToken, + where: t.token == ^token + ), + set: [authenticated_at: authenticated_at] + ) + end + + def generate_user_magic_link_token(user) do + {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login") + Firehose.Repo.insert!(user_token) + {encoded_token, user_token.token} + end + + def offset_user_token(token, amount_to_add, unit) do + dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit) + + Firehose.Repo.update_all( + from(ut in Accounts.UserToken, where: ut.token == ^token), + set: [inserted_at: dt, authenticated_at: dt] + ) + end +end diff --git a/blogex/lib/blogex.ex b/blogex/lib/blogex.ex index c0cf2ce..4e37606 100644 --- a/blogex/lib/blogex.ex +++ b/blogex/lib/blogex.ex @@ -119,5 +119,6 @@ defmodule Blogex do defdelegate get_blog!(blog_id), to: Blogex.Registry defdelegate get_blog(blog_id), to: Blogex.Registry defdelegate all_posts, to: Blogex.Registry + defdelegate all_posts_unfiltered, to: Blogex.Registry defdelegate all_tags, to: Blogex.Registry end diff --git a/blogex/lib/blogex/blog.ex b/blogex/lib/blogex/blog.ex index 8ae265d..01669b0 100644 --- a/blogex/lib/blogex/blog.ex +++ b/blogex/lib/blogex/blog.ex @@ -73,12 +73,17 @@ defmodule Blogex.Blog do @doc "Returns the base URL path for this blog." def base_path, do: @blog_base_path + @doc "Returns all compiled posts regardless of published status or date." + def unfiltered_posts, do: @posts + @doc "Returns all visible posts, newest first. Drafts are included in dev/test." def all_posts do + today = Date.utc_today() + if Blogex.show_drafts?() do - @posts + Enum.filter(@posts, &(not Date.after?(&1.date, today))) else - Enum.filter(@posts, & &1.published) + Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today))) end end @@ -86,7 +91,12 @@ defmodule Blogex.Blog do def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) @doc "Returns all unique tags across all published posts." - def all_tags, do: @tags + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end @doc "Returns all published posts matching the given tag." def posts_by_tag(tag) do @@ -95,13 +105,13 @@ defmodule Blogex.Blog do @doc "Returns a single post by slug/id, or raises." def get_post!(id) do - Enum.find(all_posts(), &(&1.id == id)) || + Enum.find(unfiltered_posts(), &(&1.id == id)) || raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}" end @doc "Returns a single post by slug/id, or nil." def get_post(id) do - Enum.find(all_posts(), &(&1.id == id)) + Enum.find(unfiltered_posts(), &(&1.id == id)) end @doc "Returns paginated posts. Page is 1-indexed." diff --git a/blogex/lib/blogex/post.ex b/blogex/lib/blogex/post.ex index a8b2775..3d6c4dd 100644 --- a/blogex/lib/blogex/post.ex +++ b/blogex/lib/blogex/post.ex @@ -44,6 +44,23 @@ defmodule Blogex.Post do published: boolean() } + @type visibility :: :draft | :scheduled | :live + + @doc "Returns the visibility of a post: :draft, :scheduled, or :live." + def visibility(%__MODULE__{published: false}), do: :draft + + def visibility(%__MODULE__{published: true, date: date}) do + if Date.after?(date, Date.utc_today()), do: :scheduled, else: :live + end + + @doc "Returns days until a scheduled post goes live, or nil." + def days_until_live(%__MODULE__{} = post) do + case visibility(post) do + :scheduled -> Date.diff(post.date, Date.utc_today()) + _ -> nil + end + end + @doc """ Build callback for NimblePublisher. diff --git a/blogex/lib/blogex/registry.ex b/blogex/lib/blogex/registry.ex index 9f02edc..5bde1f0 100644 --- a/blogex/lib/blogex/registry.ex +++ b/blogex/lib/blogex/registry.ex @@ -37,6 +37,13 @@ defmodule Blogex.Registry do |> Enum.sort_by(& &1.date, {:desc, Date}) end + @doc "Returns all posts from all blogs (unfiltered), sorted newest first." + def all_posts_unfiltered do + blogs() + |> Enum.flat_map(& &1.unfiltered_posts()) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + @doc "Returns all unique tags across all blogs." def all_tags do blogs() diff --git a/blogex/test/blogex/blog_test.exs b/blogex/test/blogex/blog_test.exs index fe89e9c..6b5afd2 100644 --- a/blogex/test/blogex/blog_test.exs +++ b/blogex/test/blogex/blog_test.exs @@ -15,6 +15,24 @@ defmodule Blogex.BlogTest do assert "draft-post" not in ids end + test "excludes future-dated posts", %{blog: blog} do + ids = blog.all_posts() |> Enum.map(& &1.id) + + refute "future-post" in ids + end + + test "includes today-dated published posts" do + {:ok, _} = FakeBlog.start([ + build(id: "today", date: Date.utc_today(), published: true), + build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true) + ]) + + ids = FakeBlog.all_posts() |> Enum.map(& &1.id) + + assert "today" in ids + refute "tomorrow" in ids + end + test "returns posts newest first", %{blog: blog} do dates = blog.all_posts() |> Enum.map(& &1.date) @@ -23,6 +41,13 @@ defmodule Blogex.BlogTest do end describe "recent_posts/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.recent_posts(100) + ids = Enum.map(posts, & &1.id) + + refute "future-post" in ids + end + test "returns at most n posts", %{blog: blog} do assert length(blog.recent_posts(2)) == 2 end @@ -35,6 +60,12 @@ defmodule Blogex.BlogTest do end describe "posts_by_tag/1" do + test "excludes future-dated posts", %{blog: blog} do + posts = blog.posts_by_tag("future-only") + + assert posts == [] + end + test "returns only posts with the given tag", %{blog: blog} do posts = blog.posts_by_tag("testing") @@ -59,6 +90,10 @@ defmodule Blogex.BlogTest do end describe "all_tags/0" do + test "excludes tags only on future-dated posts", %{blog: blog} do + refute "future-only" in blog.all_tags() + end + test "returns unique sorted tags from published posts", %{blog: blog} do tags = blog.all_tags() @@ -90,10 +125,14 @@ defmodule Blogex.BlogTest do end end - test "raises for draft post id", %{blog: blog} do - assert_raise Blogex.NotFoundError, fn -> - blog.get_post!("draft-post") - end + test "returns a future-dated published post by slug", %{blog: blog} do + post = blog.get_post!("future-post") + assert post.id == "future-post" + end + + test "returns a draft post by slug", %{blog: blog} do + post = blog.get_post!("draft-post") + assert post.id == "draft-post" end end @@ -101,6 +140,14 @@ defmodule Blogex.BlogTest do test "returns nil for unknown id", %{blog: blog} do assert blog.get_post("nope") == nil end + + test "returns a future-dated post", %{blog: blog} do + assert %{id: "future-post"} = blog.get_post("future-post") + end + + test "returns a draft post", %{blog: blog} do + assert %{id: "draft-post"} = blog.get_post("draft-post") + end end describe "paginate/2" do diff --git a/blogex/test/blogex/feed_test.exs b/blogex/test/blogex/feed_test.exs index 7409c22..b33b89a 100644 --- a/blogex/test/blogex/feed_test.exs +++ b/blogex/test/blogex/feed_test.exs @@ -65,6 +65,11 @@ defmodule Blogex.FeedTest do refute xml =~ "draft-post" end + test "excludes future-dated published posts", %{blog: blog} do + xml = Feed.rss(blog, @base_url) + refute xml =~ "future-post" + end + test "includes self-referencing atom:link", %{blog: blog} do xml = Feed.rss(blog, @base_url) @@ -95,6 +100,11 @@ defmodule Blogex.FeedTest do entry_count = xml |> String.split("") |> length() |> Kernel.-(1) assert entry_count == 2 end + + test "excludes future-dated published posts", %{blog: blog} do + xml = Feed.atom(blog, @base_url) + refute xml =~ "future-post" + end end describe "XML escaping" do diff --git a/blogex/test/blogex/post_visibility_test.exs b/blogex/test/blogex/post_visibility_test.exs new file mode 100644 index 0000000..4d98e03 --- /dev/null +++ b/blogex/test/blogex/post_visibility_test.exs @@ -0,0 +1,45 @@ +defmodule Blogex.Post.VisibilityTest do + use ExUnit.Case + + import Blogex.Test.PostBuilder + + describe "visibility/1" do + test "returns :draft when post is not published" do + post = build(published: false, date: ~D[2026-01-01]) + assert Blogex.Post.visibility(post) == :draft + end + + test "returns :scheduled when post is published with future date" do + post = build(published: true, date: ~D[2099-01-01]) + assert Blogex.Post.visibility(post) == :scheduled + end + + test "returns :live when post is published with past date" do + post = build(published: true, date: ~D[2020-01-01]) + assert Blogex.Post.visibility(post) == :live + end + + test "returns :live when post is published with today's date" do + post = build(published: true, date: Date.utc_today()) + assert Blogex.Post.visibility(post) == :live + end + end + + describe "days_until_live/1" do + test "returns positive integer for scheduled post" do + future = Date.add(Date.utc_today(), 10) + post = build(published: true, date: future) + assert Blogex.Post.days_until_live(post) == 10 + end + + test "returns nil for draft post" do + post = build(published: false, date: ~D[2099-01-01]) + assert Blogex.Post.days_until_live(post) == nil + end + + test "returns nil for live post" do + post = build(published: true, date: ~D[2020-01-01]) + assert Blogex.Post.days_until_live(post) == nil + end + end +end diff --git a/blogex/test/blogex/registry_test.exs b/blogex/test/blogex/registry_test.exs index 50a8ea2..795880b 100644 --- a/blogex/test/blogex/registry_test.exs +++ b/blogex/test/blogex/registry_test.exs @@ -8,12 +8,24 @@ defmodule Blogex.RegistryTest do def blog_id, do: :alpha def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)] def all_tags, do: ["elixir"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha), + Blogex.Test.PostBuilder.build(id: "a-draft", date: ~D[2026-03-05], blog: :alpha, published: false) + ] end defmodule BetaBlog do def blog_id, do: :beta def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)] def all_tags, do: ["devops"] + + def unfiltered_posts, + do: [ + Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta), + Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta) + ] end setup do @@ -77,6 +89,21 @@ defmodule Blogex.RegistryTest do end end + describe "all_posts_unfiltered/0" do + test "returns all posts including drafts and future-dated" do + ids = Registry.all_posts_unfiltered() |> Enum.map(& &1.id) + assert "a1" in ids + assert "a-draft" in ids + assert "b1" in ids + assert "b-future" in ids + end + + test "sorts by date descending" do + dates = Registry.all_posts_unfiltered() |> Enum.map(& &1.date) + assert dates == Enum.sort(dates, {:desc, Date}) + end + end + describe "blogs_map/0" do test "returns map keyed by blog_id" do map = Registry.blogs_map() diff --git a/blogex/test/blogex/router_test.exs b/blogex/test/blogex/router_test.exs index 221eaa2..f082674 100644 --- a/blogex/test/blogex/router_test.exs +++ b/blogex/test/blogex/router_test.exs @@ -9,7 +9,8 @@ defmodule Blogex.RouterTest do posts = [ build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]), build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]), - build(id: "draft", published: false, date: ~D[2026-03-12]) + build(id: "draft", published: false, date: ~D[2026-03-12]), + build(id: "future-post", title: "Future", tags: ["elixir"], date: ~D[2099-01-01], published: true) ] {:ok, _} = FakeBlog.start(posts, @@ -42,6 +43,12 @@ defmodule Blogex.RouterTest do assert conn.resp_body =~ "first-post" refute conn.resp_body =~ "draft" end + + test "excludes future-dated posts from feed" do + conn = call(:get, "/feed.xml") + + refute conn.resp_body =~ "future-post" + end end describe "GET /atom.xml" do @@ -70,10 +77,18 @@ defmodule Blogex.RouterTest do assert conn.status == 404 end - test "returns 404 for draft post" do + test "returns 200 for draft post accessed by slug" do conn = call(:get, "/draft") - assert conn.status == 404 + assert conn.status == 200 + end + + test "returns 200 for future-dated post accessed by slug" do + conn = call(:get, "/future-post") + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["id"] == "future-post" end end @@ -88,6 +103,14 @@ defmodule Blogex.RouterTest do assert hd(body["posts"])["id"] == "first-post" end + test "excludes future-dated posts from tag results" do + conn = call(:get, "/tag/elixir") + + body = Jason.decode!(conn.resp_body) + ids = Enum.map(body["posts"], & &1["id"]) + refute "future-post" in ids + end + test "returns empty list for unknown tag" do conn = call(:get, "/tag/unknown") @@ -113,6 +136,14 @@ defmodule Blogex.RouterTest do ids = Enum.map(body["posts"], & &1["id"]) refute "draft" in ids end + + test "excludes future-dated posts from listing" do + conn = call(:get, "/") + + body = Jason.decode!(conn.resp_body) + ids = Enum.map(body["posts"], & &1["id"]) + refute "future-post" in ids + end end defp get_content_type(conn) do diff --git a/blogex/test/support/fake_blog.ex b/blogex/test/support/fake_blog.ex index 918588e..a62d96b 100644 --- a/blogex/test/support/fake_blog.ex +++ b/blogex/test/support/fake_blog.ex @@ -51,9 +51,16 @@ defmodule Blogex.Test.FakeBlog do def description, do: get(:description) def base_path, do: get(:base_path) - def all_posts do + def unfiltered_posts do get(:posts) - |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_posts do + today = Date.utc_today() + + get(:posts) + |> Enum.filter(&(&1.published and not Date.after?(&1.date, today))) |> Enum.sort_by(& &1.date, {:desc, Date}) end @@ -71,12 +78,12 @@ defmodule Blogex.Test.FakeBlog do end def get_post!(id) do - Enum.find(all_posts(), &(&1.id == id)) || + Enum.find(unfiltered_posts(), &(&1.id == id)) || raise Blogex.NotFoundError, "post #{inspect(id)} not found" end def get_post(id) do - Enum.find(all_posts(), &(&1.id == id)) + Enum.find(unfiltered_posts(), &(&1.id == id)) end def paginate(page \\ 1, per_page \\ 10) do diff --git a/blogex/test/support/setup.ex b/blogex/test/support/setup.ex index 83903dd..7dc15fa 100644 --- a/blogex/test/support/setup.ex +++ b/blogex/test/support/setup.ex @@ -44,6 +44,12 @@ defmodule Blogex.Test.Setup do date: ~D[2026-03-12], tags: ["elixir"], published: false + ), + build( + id: "future-post", + date: ~D[2099-01-01], + tags: ["future-only"], + published: true ) ] end diff --git a/planner_request.md b/planner_request.md new file mode 100644 index 0000000..b282250 --- /dev/null +++ b/planner_request.md @@ -0,0 +1,61 @@ +# Refactoring Plan for Firehose Blog Controller Tests + +## Context +Based on context.md, we have a Phoenix blog controller with repetitive validation tests that need refactoring. + +## Goals +1. Extract test helpers to reduce code duplication +2. Standardize test naming conventions +3. Reorganize tests to follow defensive programming flow +4. Add missing negative test coverage +5. Create separate contexts for different refactorings + +## Recommended Planner Agents + +### 1. TestHelperExtractor Agent +**Purpose**: Handle Smell 1 (Repetitive Validation Tests) and Smell 4 (Redundant Layout Assertions) + +**Tasks**: +- Extract page validation test logic into `test_page_fallback/2` helper +- Create `assert_has_app_layout/1` helper for layout assertions +- Move helpers to support module or test case + +**Context Isolation**: This can run in a separate test context without affecting controller logic. + +### 2. TestOrganizer Agent +**Purpose**: Handle Smell 5 (Test Order) and Smell 3 (Inconsistent Naming) + +**Tasks**: +- Reorder test blocks: validation first, then success cases, then edge cases +- Standardize all test descriptions to follow pattern: "GET /blog/:type/:slug returns [result]" +- Rename describe blocks to follow semantic order + +**Context Isolation**: Pure test organization, no production code changes. + +### 3. CoverageExpander Agent +**Purpose**: Handle Smell 2 (Missing Negative Tests) and Smell 8 (Edge Cases) + +**Tasks**: +- Add test for unknown blog_id (`/blog/invalid`) +- Add test for empty page parameter (`?page=`) +- Add test for very large page numbers +- Add test for invalid blog halt behavior (Smell 6) + +**Context Isolation**: Adds new tests without modifying existing logic. + +### 4. ResponseHelperCreator Agent +**Purpose**: Handle Smell 7 (Mixed Response Types) + +**Tasks**: +- Create `assert_html/2` and `assert_json/2` helpers +- Ensure proper content-type verification +- Update existing tests to use new helpers + +## Execution Strategy +Run each agent in isolated contexts: +1. TestHelperExtractor → creates helper functions +2. ResponseHelperCreator → builds response assertions +3. TestOrganizer → reorganizes existing structure +4. CoverageExpander → adds new test cases + +This keeps the main thread clean and allows focused changes per agent. \ No newline at end of file