Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/util/user_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions doc/config/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). | |
125 changes: 125 additions & 0 deletions spec/util/user_creator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 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

# 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
# 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

# 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