tests: minitest + rack-test suite, bin/test with ephemeral Postgres
This commit is contained in:
parent
8468b3d099
commit
43bed98329
13 changed files with 456 additions and 2 deletions
40
test/admin_auth_test.rb
Normal file
40
test/admin_auth_test.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
require 'base64'
|
||||
|
||||
class AdminAuthTest < Minitest::Test
|
||||
OK_APP = ->(_env) { [200, {}, ['ok']] }
|
||||
|
||||
def setup
|
||||
@auth = AdminAuth.new(OK_APP)
|
||||
end
|
||||
|
||||
def env_for(path, user: nil, pass: nil)
|
||||
env = { 'PATH_INFO' => path }
|
||||
if user
|
||||
env['HTTP_AUTHORIZATION'] = "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"
|
||||
end
|
||||
env
|
||||
end
|
||||
|
||||
def test_correct_credentials_pass
|
||||
assert_equal 200, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'test-password'))[0]
|
||||
end
|
||||
|
||||
def test_wrong_password_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'nope'))[0]
|
||||
end
|
||||
|
||||
def test_wrong_user_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'other', pass: 'test-password'))[0]
|
||||
end
|
||||
|
||||
def test_missing_header_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin'))[0]
|
||||
end
|
||||
|
||||
def test_sibling_paths_not_gated
|
||||
assert_equal 200, @auth.call(env_for('/'))[0]
|
||||
assert_equal 200, @auth.call(env_for('/admin-invoice-form.js'))[0]
|
||||
end
|
||||
end
|
||||
174
test/app_test.rb
Normal file
174
test/app_test.rb
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
class AppTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
|
||||
def app
|
||||
AsxpioWeb
|
||||
end
|
||||
|
||||
def setup
|
||||
Mail::TestMailer.deliveries.clear
|
||||
TestDb.clean! if TestDb.available?
|
||||
end
|
||||
|
||||
# Each test that passes contact validation must use a fresh IP: the app-level
|
||||
# rate limiter (5/hour) is shared process state, so the counter must be
|
||||
# unique across the whole run, not per test.
|
||||
@@ip_counter = 0
|
||||
def fresh_ip
|
||||
@@ip_counter += 1
|
||||
"10.9.#{@@ip_counter / 250}.#{@@ip_counter % 250}"
|
||||
end
|
||||
|
||||
def csrf_token_from(path, env = {})
|
||||
get path, {}, env
|
||||
assert last_response.ok?, "GET #{path} failed: #{last_response.status}"
|
||||
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] ||
|
||||
flunk("no CSRF token found on #{path}")
|
||||
end
|
||||
|
||||
def admin_env(extra = {})
|
||||
{ 'HTTP_AUTHORIZATION' =>
|
||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }.merge(extra)
|
||||
end
|
||||
|
||||
# --- public pages ---------------------------------------------------------
|
||||
|
||||
def test_index_renders
|
||||
get '/'
|
||||
assert last_response.ok?
|
||||
end
|
||||
|
||||
# --- contact form ---------------------------------------------------------
|
||||
|
||||
def contact_params(over = {})
|
||||
{ name: 'Visitor', email: 'visitor@example.com', subject: 'Hello',
|
||||
message: 'A message.', website: '' }.merge(over)
|
||||
end
|
||||
|
||||
def test_contact_without_csrf_token_forbidden
|
||||
post '/contact', contact_params
|
||||
assert_equal 403, last_response.status
|
||||
end
|
||||
|
||||
def test_contact_honeypot_pretends_success_and_sends_nothing
|
||||
token = csrf_token_from('/')
|
||||
post '/contact', contact_params(website: 'spam', authenticity_token: token)
|
||||
assert_equal 302, last_response.status
|
||||
assert_match %r{/thanks}, last_response.location
|
||||
assert_empty Mail::TestMailer.deliveries
|
||||
end
|
||||
|
||||
def test_contact_invalid_email_rejected
|
||||
token = csrf_token_from('/')
|
||||
post '/contact', contact_params(email: 'not-an-email', authenticity_token: token)
|
||||
assert_equal 422, last_response.status
|
||||
assert_empty Mail::TestMailer.deliveries
|
||||
end
|
||||
|
||||
def test_contact_valid_submission_sends_two_mails
|
||||
token = csrf_token_from('/')
|
||||
post '/contact', contact_params(authenticity_token: token),
|
||||
'HTTP_X_FORWARDED_FOR' => fresh_ip
|
||||
assert_equal 302, last_response.status
|
||||
assert_equal 2, Mail::TestMailer.deliveries.size
|
||||
to_owner, to_visitor = Mail::TestMailer.deliveries
|
||||
assert_includes to_owner.to, ENV['MAIL_TO']
|
||||
assert_includes to_visitor.to, 'visitor@example.com'
|
||||
end
|
||||
|
||||
def test_contact_rate_limited_after_five
|
||||
ip = fresh_ip
|
||||
token = csrf_token_from('/')
|
||||
5.times do
|
||||
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
|
||||
assert_equal 302, last_response.status
|
||||
end
|
||||
post '/contact', contact_params(authenticity_token: token), 'HTTP_X_FORWARDED_FOR' => ip
|
||||
assert_equal 429, last_response.status
|
||||
end
|
||||
|
||||
# --- admin ------------------------------------------------------------
|
||||
|
||||
def test_admin_requires_auth
|
||||
get '/admin/invoices'
|
||||
assert_equal 401, last_response.status
|
||||
end
|
||||
|
||||
def test_admin_list_with_auth
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
get '/admin/invoices', {}, admin_env
|
||||
assert last_response.ok?
|
||||
end
|
||||
|
||||
def invoice_form_params(token)
|
||||
{ authenticity_token: token,
|
||||
client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
|
||||
currency: 'EUR', gel_rate: '3.05',
|
||||
issued_on: Date.today.to_s, due_on: (Date.today + 14).to_s, notes: '',
|
||||
ltc_address: '', ltc_rate: '', ltc_amount: '',
|
||||
items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } }
|
||||
end
|
||||
|
||||
def test_create_invoice_end_to_end
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
token = csrf_token_from('/admin/invoices/new', admin_env)
|
||||
|
||||
post '/admin/invoices', invoice_form_params(token), admin_env
|
||||
assert_equal 302, last_response.status, last_response.body
|
||||
|
||||
invoice = Invoice.first
|
||||
refute_nil invoice
|
||||
assert_equal BigDecimal('201'), invoice.total
|
||||
assert_equal "invoices/#{invoice.number}-#{invoice.uuid}.pdf", invoice.pdf_key
|
||||
assert_match %r{/admin/invoices/#{invoice.uuid}}, last_response.location
|
||||
end
|
||||
|
||||
def test_create_invoice_validation_failure_rerenders_form
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
token = csrf_token_from('/admin/invoices/new', admin_env)
|
||||
|
||||
params = invoice_form_params(token)
|
||||
params[:items]['0']['qty'] = '1,5'
|
||||
post '/admin/invoices', params, admin_env
|
||||
assert_equal 422, last_response.status
|
||||
assert_nil Invoice.first
|
||||
end
|
||||
|
||||
# --- public invoice pages -----------------------------------------------
|
||||
|
||||
def create_invoice!
|
||||
inv = Invoice.build(
|
||||
client_name: 'ACME', client_email: 'billing@example.com',
|
||||
currency: 'EUR', gel_rate: '3.05',
|
||||
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
|
||||
)
|
||||
inv.pdf_key = "invoices/#{inv.number}-#{inv.uuid}.pdf"
|
||||
inv.save_changes
|
||||
inv
|
||||
end
|
||||
|
||||
def test_public_landing_page
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
inv = create_invoice!
|
||||
get "/i/#{inv.uuid}"
|
||||
assert last_response.ok?
|
||||
assert_includes last_response.body, inv.number
|
||||
assert_includes last_response.body, 'ACME'
|
||||
end
|
||||
|
||||
def test_public_pdf_redirects_to_presigned_url
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
inv = create_invoice!
|
||||
get "/i/#{inv.uuid}/pdf"
|
||||
assert_equal 302, last_response.status
|
||||
assert_includes last_response.location, 'X-Amz-Signature'
|
||||
end
|
||||
|
||||
def test_unknown_invoice_404s
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
get "/i/#{SecureRandom.uuid}"
|
||||
assert_equal 404, last_response.status
|
||||
end
|
||||
end
|
||||
115
test/invoice_test.rb
Normal file
115
test/invoice_test.rb
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
class InvoiceTest < Minitest::Test
|
||||
def setup
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
TestDb.clean!
|
||||
@year = Date.today.year
|
||||
end
|
||||
|
||||
def insert_number(num)
|
||||
DB.connection[:invoices].insert(
|
||||
uuid: SecureRandom.uuid, number: num, client_name: 'c', client_email: 'c@example.com',
|
||||
currency: 'EUR', gel_rate: 3, subtotal: 1,
|
||||
items: [{ 'description' => 'x', 'qty' => '1', 'unit_price' => '1' }].to_json,
|
||||
issued_on: Date.today, due_on: Date.today + 14, pdf_key: 'k', created_at: Time.now.utc
|
||||
)
|
||||
end
|
||||
|
||||
def test_allocate_number_starts_at_one
|
||||
assert_equal "INV-#{@year}-0001", Invoice.allocate_number
|
||||
end
|
||||
|
||||
def test_allocate_number_increments
|
||||
insert_number("INV-#{@year}-0001")
|
||||
insert_number("INV-#{@year}-0002")
|
||||
assert_equal "INV-#{@year}-0003", Invoice.allocate_number
|
||||
end
|
||||
|
||||
def test_allocate_number_sorts_numerically_past_9999
|
||||
insert_number("INV-#{@year}-9999")
|
||||
insert_number("INV-#{@year}-10000")
|
||||
assert_equal "INV-#{@year}-10001", Invoice.allocate_number
|
||||
end
|
||||
|
||||
def test_allocate_number_ignores_other_years
|
||||
insert_number("INV-#{@year - 1}-0500")
|
||||
assert_equal "INV-#{@year}-0001", Invoice.allocate_number
|
||||
end
|
||||
|
||||
def test_duplicate_number_raises_unique_violation
|
||||
insert_number("INV-#{@year}-0001")
|
||||
assert_raises(Sequel::UniqueConstraintViolation) { insert_number("INV-#{@year}-0001") }
|
||||
end
|
||||
|
||||
def test_build_round_trip
|
||||
inv = Invoice.build(
|
||||
client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
|
||||
currency: 'EUR', gel_rate: '3.05', issued_on: '', due_on: '', notes: '',
|
||||
items: [{ 'description' => 'work', 'qty' => '2', 'unit_price' => '100.50' }]
|
||||
)
|
||||
inv.pdf_key = "invoices/#{inv.number}-#{inv.uuid}.pdf"
|
||||
inv.save_changes
|
||||
|
||||
saved = Invoice[uuid: inv.uuid]
|
||||
assert_equal BigDecimal('201'), saved.total
|
||||
assert_equal BigDecimal('613.05'), saved.total_gel
|
||||
assert_equal 'pending', saved.status
|
||||
refute saved.ltc?
|
||||
end
|
||||
|
||||
def test_build_derives_ltc_amount_from_rate
|
||||
inv = Invoice.build(
|
||||
client_name: 'ACME', client_email: 'billing@example.com',
|
||||
currency: 'EUR', gel_rate: '3.05',
|
||||
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }],
|
||||
ltc_address: 'ltc1qexampleexampleexampleexample', ltc_rate: '80', ltc_amount: ''
|
||||
)
|
||||
assert inv.ltc?
|
||||
assert_equal BigDecimal('1.25'), inv.ltc_amount_due
|
||||
end
|
||||
end
|
||||
|
||||
class InvoiceValidationTest < Minitest::Test
|
||||
BASE = {
|
||||
client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR',
|
||||
gel_rate: '3.05', ltc_address: '', ltc_rate: '', ltc_amount: '',
|
||||
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
|
||||
}.freeze
|
||||
|
||||
def setup
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
@app = AsxpioWeb.new!
|
||||
end
|
||||
|
||||
def validate(over = {})
|
||||
@app.send(:validate_invoice_params, BASE.merge(over).dup)
|
||||
end
|
||||
|
||||
def test_valid_params_pass
|
||||
assert_empty validate
|
||||
end
|
||||
|
||||
def test_gel_rate_must_be_positive
|
||||
assert_includes validate(gel_rate: '0'), :gel_rate
|
||||
assert_includes validate(gel_rate: '-2.9'), :gel_rate
|
||||
refute_includes validate(gel_rate: '2.95'), :gel_rate
|
||||
end
|
||||
|
||||
def test_item_numbers_must_parse
|
||||
assert_includes validate(items: [{ 'description' => 'w', 'qty' => '1,5', 'unit_price' => '100' }]), :items
|
||||
assert_includes validate(items: [{ 'description' => 'w', 'qty' => '1', 'unit_price' => 'abc' }]), :items
|
||||
end
|
||||
|
||||
def test_blank_item_numbers_allowed
|
||||
assert_empty validate(items: [{ 'description' => 'w', 'qty' => '', 'unit_price' => '' }])
|
||||
end
|
||||
|
||||
def test_at_least_one_item_required
|
||||
assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items
|
||||
end
|
||||
|
||||
def test_ltc_address_format
|
||||
assert_includes validate(ltc_address: 'not-an-address'), :ltc_address
|
||||
end
|
||||
end
|
||||
35
test/rate_limit_test.rb
Normal file
35
test/rate_limit_test.rb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
class RateLimitTest < Minitest::Test
|
||||
def test_allows_up_to_limit_then_denies
|
||||
rl = RateLimit.new(limit: 5, window: 3600)
|
||||
assert (1..5).all? { rl.allow?('1.2.3.4') }
|
||||
refute rl.allow?('1.2.3.4')
|
||||
end
|
||||
|
||||
def test_keys_are_independent
|
||||
rl = RateLimit.new(limit: 1, window: 3600)
|
||||
assert rl.allow?('a')
|
||||
refute rl.allow?('a')
|
||||
assert rl.allow?('b')
|
||||
end
|
||||
|
||||
def test_sweep_removes_stale_keys
|
||||
rl = RateLimit.new(limit: 5, window: 3600)
|
||||
hits = rl.instance_variable_get(:@hits)
|
||||
old = Time.now.to_i - 7200
|
||||
50.times { |i| hits["stale-#{i}"] << old }
|
||||
rl.sweep!
|
||||
assert_empty hits.keys.grep(/\Astale-/)
|
||||
end
|
||||
|
||||
def test_allow_prunes_opportunistically
|
||||
rl = RateLimit.new(limit: 5, window: 3600)
|
||||
hits = rl.instance_variable_get(:@hits)
|
||||
old = Time.now.to_i - 7200
|
||||
50.times { |i| hits["stale-#{i}"] << old }
|
||||
RateLimit::SWEEP_EVERY.times { rl.allow?('fresh') }
|
||||
assert_empty hits.keys.grep(/\Astale-/)
|
||||
assert hits.key?('fresh')
|
||||
end
|
||||
end
|
||||
43
test/test_helper.rb
Normal file
43
test/test_helper.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
ENV['RACK_ENV'] = 'test'
|
||||
|
||||
# Dummy env so the app class loads; nothing here reaches a real service.
|
||||
ENV['SESSION_SECRET'] ||= 'test-secret-0000000000000000000000000000000000000000000000000000'
|
||||
ENV['SMTP_ADDR'] ||= 'localhost'
|
||||
ENV['SMTP_PORT'] ||= '587'
|
||||
ENV['SMTP_USER'] ||= 'test'
|
||||
ENV['SMTP_PASSWORD'] ||= 'test'
|
||||
ENV['MAIL_FROM'] ||= 'Test <test@example.com>'
|
||||
ENV['MAIL_TO'] ||= 'owner@example.com'
|
||||
ENV['ADMIN_USER'] ||= 'admin'
|
||||
ENV['ADMIN_PASSWORD'] ||= 'test-password'
|
||||
|
||||
# DB-backed tests run only against an explicit test database (bin/test
|
||||
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
|
||||
if ENV['TEST_DATABASE_URL']
|
||||
ENV['DATABASE_URL'] = ENV['TEST_DATABASE_URL']
|
||||
ENV['S3_ENDPOINT'] ||= 'http://127.0.0.1:1'
|
||||
ENV['S3_PUBLIC_ENDPOINT'] ||= 'http://127.0.0.1:1'
|
||||
ENV['S3_ACCESS_KEY'] ||= 'test'
|
||||
ENV['S3_SECRET_KEY'] ||= 'test'
|
||||
ENV['S3_BUCKET'] ||= 'test-bucket'
|
||||
require 'aws-sdk-s3'
|
||||
Aws.config[:s3] = { stub_responses: true } # no S3 call leaves the process
|
||||
else
|
||||
ENV.delete('DATABASE_URL')
|
||||
end
|
||||
|
||||
require_relative '../asxpio'
|
||||
require 'minitest/autorun'
|
||||
require 'rack/test'
|
||||
|
||||
Mail.defaults { delivery_method :test }
|
||||
|
||||
module TestDb
|
||||
def self.available?
|
||||
!ENV['DATABASE_URL'].nil?
|
||||
end
|
||||
|
||||
def self.clean!
|
||||
DB.connection[:invoices].delete
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue