diff --git a/.dockerignore b/.dockerignore index ab6fdaa..ee237fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ tmp flake.nix flake.lock CLAUDE.md +test +Rakefile +bin diff --git a/CLAUDE.md b/CLAUDE.md index 4e135c2..dd39811 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,7 @@ The hedgehog is the user's pre-IE personal mark; once the IE has its own logo, t - **Generate a new SESSION_SECRET:** `openssl rand -hex 64` (then update Forgejo secret). - **Generate a new ADMIN_PASSWORD:** `openssl rand -base64 24`. **Save it to a password manager** — Forgejo secrets are write-only. - **Local dev:** `nix develop`, then `bundle exec rerun -- rackup -p 3000`. Visit `http://localhost:3000`. Without `DATABASE_URL` the contact form still works; invoicing routes return 503. +- **Run tests:** `nix develop -c bin/test` — full suite (minitest + rack-test) against an ephemeral Postgres it provisions and tears down itself. `bundle exec rake test` runs without a DB, skipping DB-backed tests. Mail uses `Mail::TestMailer`; S3 uses aws-sdk stubbed responses; tests never touch real services, and `.env` is deliberately not loaded when `RACK_ENV=test`. - **Local image build (sanity check):** `docker compose build` - **Production deploy:** automatic via `.forgejo/workflows/deploy.yaml` on push to `main`. Pipeline: 1. Kaniko builds the image and pushes it to the Forgejo registry. diff --git a/Dockerfile b/Dockerfile index f339a65..6da7adc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ ENV GEM_HOME=/app/bundle ENV PATH="${GEM_HOME}/bin:${PATH}" COPY --chown=asxpio Gemfile Gemfile.lock* ./ -RUN bundle install --without development +RUN bundle install --without development:test COPY --chown=asxpio . . diff --git a/Gemfile b/Gemfile index ef4fa67..cee4a54 100644 --- a/Gemfile +++ b/Gemfile @@ -26,3 +26,9 @@ gem 'rexml', '~> 3.4' group :development do gem 'rerun', '~> 0.14' end + +group :test do + gem 'minitest', '~> 5.25' + gem 'rack-test', '~> 2.2' + gem 'rake', '~> 13.2' +end diff --git a/Gemfile.lock b/Gemfile.lock index 08d47b6..e6b3057 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -51,6 +51,7 @@ GEM net-smtp matrix (0.4.3) mini_mime (1.1.5) + minitest (5.27.0) multi_json (1.21.1) mustermann (3.1.1) net-imap (0.6.4) @@ -87,8 +88,11 @@ GEM rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) rackup (2.3.1) rack (>= 3) + rake (13.4.2) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) @@ -138,6 +142,7 @@ DEPENDENCIES dotenv (~> 3.1) erubi (~> 1.13) mail (~> 2.8) + minitest (~> 5.25) pg (~> 1.5) prawn (~> 2.5) prawn-table (~> 0.2) @@ -145,7 +150,9 @@ DEPENDENCIES rack (~> 3.2) rack-protection (~> 4.2) rack-session (~> 2.1) + rack-test (~> 2.2) rackup (~> 2.3) + rake (~> 13.2) rerun (~> 0.14) rexml (~> 3.4) rqrcode (~> 2.2) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c1a67e1 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.pattern = 'test/*_test.rb' + t.warning = false +end + +task default: :test diff --git a/asxpio.rb b/asxpio.rb index a99a732..df0935f 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -7,7 +7,9 @@ require 'uri' begin require 'dotenv' - Dotenv.load + # Tests control ENV themselves; a developer's .env must not leak in (it may + # point DATABASE_URL at a real database). + Dotenv.load unless ENV['RACK_ENV'] == 'test' rescue LoadError end diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..9a3609f --- /dev/null +++ b/bin/test @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Full test suite against an ephemeral Postgres. Run inside `nix develop` +# (needs initdb/pg_ctl from the dev shell). Without a database you can still +# run `bundle exec rake test` — DB-backed tests skip. +set -euo pipefail +cd "$(dirname "$0")/.." + +PORT="${TEST_PG_PORT:-54329}" +TMP="$(mktemp -d)" +trap 'pg_ctl -D "$TMP/pg" stop -m immediate >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT + +initdb -D "$TMP/pg" -A trust -U postgres >/dev/null +printf "unix_socket_directories = ''\nlisten_addresses = '127.0.0.1'\nport = %s\n" "$PORT" \ + >> "$TMP/pg/postgresql.conf" +pg_ctl -D "$TMP/pg" -l "$TMP/pg.log" start >/dev/null +createdb -h 127.0.0.1 -p "$PORT" -U postgres asxpio_test + +TEST_DATABASE_URL="postgres://postgres@127.0.0.1:$PORT/asxpio_test" \ + bundle exec rake test "$@" diff --git a/test/admin_auth_test.rb b/test/admin_auth_test.rb new file mode 100644 index 0000000..76908ff --- /dev/null +++ b/test/admin_auth_test.rb @@ -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 diff --git a/test/app_test.rb b/test/app_test.rb new file mode 100644 index 0000000..a4eb6ba --- /dev/null +++ b/test/app_test.rb @@ -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 diff --git a/test/invoice_test.rb b/test/invoice_test.rb new file mode 100644 index 0000000..aa0406a --- /dev/null +++ b/test/invoice_test.rb @@ -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 diff --git a/test/rate_limit_test.rb b/test/rate_limit_test.rb new file mode 100644 index 0000000..240acfd --- /dev/null +++ b/test/rate_limit_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..d78c1b2 --- /dev/null +++ b/test/test_helper.rb @@ -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 ' +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