From 1786da38f9ba525b677b4c65d9048ae13132e7e1 Mon Sep 17 00:00:00 2001 From: Max Levine Date: Thu, 21 May 2026 23:39:56 +0100 Subject: [PATCH 1/2] feat(make-user): add non-interactive mode driven by POSTAL_INITIAL_USER_* env vars --- app/util/user_creator.rb | 51 ++++++++++++++++ doc/config/environment-variables.md | 4 ++ spec/util/user_creator_spec.rb | 91 +++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 spec/util/user_creator_spec.rb diff --git a/app/util/user_creator.rb b/app/util/user_creator.rb index 49b5d7016..ce95ea86f 100644 --- a/app/util/user_creator.rb +++ b/app/util/user_creator.rb @@ -4,9 +4,60 @@ module UserCreator + ENV_PREFIX = "POSTAL_INITIAL_USER_" + REQUIRED_ENV_VARS = %w[EMAIL FIRST_NAME LAST_NAME PASSWORD].map { |s| "#{ENV_PREFIX}#{s}" }.freeze + class << self + # Create (or update) a user. If POSTAL_INITIAL_USER_EMAIL is set in the + # environment, runs non-interactively using POSTAL_INITIAL_USER_* + # variables and upserts by email. Otherwise prompts on STDIN. def start(&block) + if non_interactive_env? + start_from_env(&block) + else + start_interactive(&block) + end + end + + private + + def non_interactive_env? + ENV["#{ENV_PREFIX}EMAIL"].to_s.strip != "" + end + + def start_from_env(&block) + puts "\e[32mPostal User Creator\e[0m (non-interactive mode)" + + missing = REQUIRED_ENV_VARS.reject { |k| ENV[k].to_s.strip != "" } + unless missing.empty? + warn "\e[31mFailed to create user\e[0m" + warn " * missing required environment variables: #{missing.join(', ')}" + exit 1 + end + + email = ENV.fetch("#{ENV_PREFIX}EMAIL") + user = User.find_by(email_address: email) || User.new + user.email_address = email + user.first_name = ENV.fetch("#{ENV_PREFIX}FIRST_NAME") + user.last_name = ENV.fetch("#{ENV_PREFIX}LAST_NAME") + user.password = ENV.fetch("#{ENV_PREFIX}PASSWORD") + + block.call(user) if block_given? + + action = user.new_record? ? "created" : "updated" + if user.save + puts "User \e[32m#{user.email_address}\e[0m has been #{action}" + else + warn "\e[31mFailed to create user\e[0m" + user.errors.full_messages.each do |error| + warn " * #{error}" + end + exit 1 + end + end + + def start_interactive(&block) cli = HighLine.new puts "\e[32mPostal User Creator\e[0m" puts "Enter the information required to create a new Postal user." diff --git a/doc/config/environment-variables.md b/doc/config/environment-variables.md index 940424e04..14e44e7b7 100644 --- a/doc/config/environment-variables.md +++ b/doc/config/environment-variables.md @@ -114,3 +114,7 @@ This document contains all the environment variables which are available for thi | `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) | | | `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) | | | `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) | | +| `POSTAL_INITIAL_USER_EMAIL` | String | E-mail address for the user created or updated by `postal make-user`. When set, `make-user` runs non-interactively and upserts by e-mail (creates if absent, updates first/last name + password if present). All four `POSTAL_INITIAL_USER_*` variables must be set together. | | +| `POSTAL_INITIAL_USER_FIRST_NAME` | String | First name for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | | +| `POSTAL_INITIAL_USER_LAST_NAME` | String | Last name for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | | +| `POSTAL_INITIAL_USER_PASSWORD` | String | Password for the user created or updated by `postal make-user` (non-interactive mode; see `POSTAL_INITIAL_USER_EMAIL`). | | diff --git a/spec/util/user_creator_spec.rb b/spec/util/user_creator_spec.rb new file mode 100644 index 000000000..4ae2d2dac --- /dev/null +++ b/spec/util/user_creator_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UserCreator do + let(:env) do + { + "POSTAL_INITIAL_USER_EMAIL" => "first-admin@example.com", + "POSTAL_INITIAL_USER_FIRST_NAME" => "First", + "POSTAL_INITIAL_USER_LAST_NAME" => "Admin", + "POSTAL_INITIAL_USER_PASSWORD" => "correct-horse-battery-staple" + } + end + + describe ".start (non-interactive mode)" do + before { stub_const("ENV", ENV.to_hash.merge(env)) } + + context "when POSTAL_INITIAL_USER_EMAIL is set and the user does not exist" do + it "creates the user with the values from the environment" do + expect { described_class.start }.to change(User, :count).by(1) + user = User.find_by(email_address: "first-admin@example.com") + expect(user.first_name).to eq("First") + expect(user.last_name).to eq("Admin") + expect(user.authenticate("correct-horse-battery-staple")).to be_truthy + end + + it "yields the user to the block before saving" do + described_class.start do |u| + u.admin = true + u.email_verified_at = Time.now + end + user = User.find_by(email_address: "first-admin@example.com") + expect(user.admin).to be true + expect(user.email_verified_at).to be_present + end + end + + context "when a user with that email already exists" do + let!(:existing) do + create(:user, + email_address: "first-admin@example.com", + first_name: "Old", + last_name: "Name", + password: "old-password", + admin: false) + end + + it "updates the existing user (does not create a new one)" do + expect { described_class.start { |u| u.admin = true } }.not_to change(User, :count) + existing.reload + expect(existing.first_name).to eq("First") + expect(existing.last_name).to eq("Admin") + expect(existing.admin).to be true + expect(existing.authenticate("correct-horse-battery-staple")).to be_truthy + end + end + + context "when one of the required env vars is missing" do + it "exits non-zero and reports the missing var" do + stub_const("ENV", ENV.to_hash.merge(env).merge("POSTAL_INITIAL_USER_PASSWORD" => "")) + expect { + expect { described_class.start }.to output(/POSTAL_INITIAL_USER_PASSWORD/).to_stderr + }.to raise_error(SystemExit) + end + end + + context "when validation fails (e.g. malformed email)" do + it "exits non-zero and prints the validation errors" do + stub_const("ENV", ENV.to_hash.merge(env).merge("POSTAL_INITIAL_USER_EMAIL" => "no-at-sign")) + expect { + expect { described_class.start }.to output(/Failed to create user/).to_stderr + }.to raise_error(SystemExit) + end + end + end + + describe ".start (interactive mode)" do + # The HighLine prompts are exercised by manual testing; we only verify + # here that the interactive branch is selected when the env var is absent. + before { stub_const("ENV", ENV.to_hash.reject { |k, _| k.start_with?("POSTAL_INITIAL_USER_") }) } + + it "uses HighLine when POSTAL_INITIAL_USER_EMAIL is not set" do + expect(HighLine).to receive(:new).and_call_original + # Stub HighLine#ask to short-circuit the prompts. + allow_any_instance_of(HighLine).to receive(:ask).and_return("ignored") + # We expect the save to fail because the email is "ignored", but that's + # fine — the assertion is that we entered the interactive code path. + described_class.start + end + end +end From 9283a9b0feb764f4f4101a69a97eee313d398e4c Mon Sep 17 00:00:00 2001 From: Max Levine Date: Thu, 21 May 2026 23:53:41 +0100 Subject: [PATCH 2/2] test(make-user): add regression guards for case-insensitive upsert and partial-env fallthrough --- spec/util/user_creator_spec.rb | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/spec/util/user_creator_spec.rb b/spec/util/user_creator_spec.rb index 4ae2d2dac..5aa732c4d 100644 --- a/spec/util/user_creator_spec.rb +++ b/spec/util/user_creator_spec.rb @@ -72,6 +72,24 @@ }.to raise_error(SystemExit) end end + + # The User model declares case-insensitive uniqueness on email_address. + # We rely on the underlying DB collation matching — if a future change + # switches to a case-sensitive collation, this upsert would silently + # create a duplicate (which the unique constraint then rejects), so we + # pin the behaviour with a test. + context "when the existing user's email differs only in case" do + let!(:existing) do + create(:user, email_address: "First-Admin@Example.com", first_name: "Old") + end + + it "updates the existing user, not a new one" do + # env email is "first-admin@example.com" (lowercase) + expect { described_class.start }.not_to change(User, :count) + existing.reload + expect(existing.first_name).to eq("First") + end + end end describe ".start (interactive mode)" do @@ -87,5 +105,21 @@ # fine — the assertion is that we entered the interactive code path. described_class.start end + + # Pin the contract that EMAIL alone gates the mode — even if FIRST_NAME / + # LAST_NAME / PASSWORD are set. Without this guard, a future refactor + # could start triggering non-interactive mode from any of the four vars, + # which would surprise operators who set partial config. + it "falls through to interactive even if other POSTAL_INITIAL_USER_* vars are set" do + stub_const("ENV", ENV.to_hash.merge( + "POSTAL_INITIAL_USER_FIRST_NAME" => "First", + "POSTAL_INITIAL_USER_LAST_NAME" => "Admin", + "POSTAL_INITIAL_USER_PASSWORD" => "x" + # POSTAL_INITIAL_USER_EMAIL intentionally not set + )) + expect(HighLine).to receive(:new).and_call_original + allow_any_instance_of(HighLine).to receive(:ask).and_return("ignored") + described_class.start + end end end