diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cf252f7 --- /dev/null +++ b/Rakefile @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.test_files = FileList['test/*_test.rb'] + t.warning = false +end + +desc 'Run the app locally on http://127.0.0.1:8047' +task :server do + sh 'puma --bind tcp://127.0.0.1:8047 config.ru' +end + +task default: :test diff --git a/test/app_test.rb b/test/app_test.rb new file mode 100644 index 0000000..e27c6bd --- /dev/null +++ b/test/app_test.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require 'sqlite3' + +class AppTest < Minitest::Test + include TestHelpers + + def setup + reset_app_store! + end + + # + # nothing is reachable without a session + # + + def test_board_redirects_to_login_when_signed_out + response = Client.new.get('/') + + assert_equal 302, response.status + assert_equal '/login', URI(response.headers['Location']).path + end + + def test_api_returns_401_json_when_signed_out + response = Client.new.get('/api/items') + + assert_equal 401, response.status + assert_equal 'unauthenticated', json_body(response)['error'] + end + + def test_api_writes_are_refused_when_signed_out + response = Client.new.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }]) + + assert_equal 401, response.status + assert_equal 0, ValidBoard::STORE.item_count + end + + def test_login_page_is_reachable_when_signed_out + response = Client.new.get('/login') + + assert_equal 200, response.status + assert_includes response.body, 'ValidBoard' + end + + def test_health_check_needs_no_session + response = Client.new.get('/healthz') + + assert_equal 200, response.status + assert_equal "ok\n", response.body + end + + # + # signing in + # + + def test_wrong_password_is_refused + response = Client.new(remote_addr: '10.0.0.1').login('not-the-password') + + assert_equal 401, response.status + assert_includes response.body, 'Wrong password' + end + + def test_correct_password_starts_a_session + client = Client.new + response = client.login + + assert_equal 303, response.status + assert_equal '/', URI(response.headers['Location']).path + refute_nil client.session_cookie + end + + def test_session_cookie_is_httponly_and_samesite + response = Client.new.login + cookie = Array(response.headers['Set-Cookie']).flat_map { |h| h.split("\n") } + .find { |h| h.start_with?('validboard.session=') } + + assert_match(/HttpOnly/i, cookie) + assert_match(/SameSite=Lax/i, cookie) + end + + def test_signing_out_ends_the_session + client = signed_in_client + assert_equal 200, client.get('/api/items').status + + response = client.post('/logout') + assert_equal 303, response.status + + assert_equal 401, client.get('/api/items').status + end + + def test_repeated_failures_are_locked_out + client = Client.new(remote_addr: '10.0.0.99') + + ValidBoard::LoginThrottle::MAX_FAILURES.times do + assert_equal 401, client.login('wrong').status + end + + response = client.login('wrong') + assert_equal 429, response.status + assert_includes response.body, 'Too many failed attempts' + + # Still locked out even with the right password — that's the point. + assert_equal 429, client.login.status + end + + def test_lockout_is_per_client + blocked = Client.new(remote_addr: '10.0.0.98') + ValidBoard::LoginThrottle::MAX_FAILURES.times { blocked.login('wrong') } + + assert_equal 429, blocked.login.status + assert_equal 303, Client.new(remote_addr: '10.0.0.97').login.status + end + + # + # the board page + # + + def test_board_page_is_served_once_signed_in + response = signed_in_client.get('/') + + assert_equal 200, response.status + assert_includes response.body, 'id="nb-bootstrap"' + assert_includes response.body, 'class Storage_Server' + end + + def test_board_page_carries_the_stored_items + ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }]) + + assert_equal({ 'config' => '{"theme":"dark"}' }, bootstrap_from(signed_in_client.get('/'))) + end + + def test_bootstrap_escapes_markup_so_a_note_cannot_break_out + payload = JSON.generate({ 'title' => 'closing tag' }) + ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'board.1.1', 'value' => payload }]) + + response = signed_in_client.get('/') + script = response.body[/ in the block must be the one that closes it. + assert_equal 1, script.scan(%r{}).length + assert_includes script, 'u003c' + + # ...and it still parses back to exactly what was stored. + assert_equal payload, bootstrap_from(response)['board.1.1'] + end + + def test_board_page_is_not_cached + assert_match(/no-cache/, signed_in_client.get('/').headers['Cache-Control'].to_s) + end + + # + # the storage API + # + + def test_write_then_read_back + client = signed_in_client + + response = client.post_json('/api/items', ops: [ + { op: 'set', key: 'config', value: '{"a":1}' }, + { op: 'set', key: 'board.7.meta', value: '{"b":2}' } + ]) + + assert_equal 200, response.status + assert_equal 2, json_body(response)['applied'] + + assert_equal({ 'config' => '{"a":1}', 'board.7.meta' => '{"b":2}' }, + json_body(client.get('/api/items'))) + end + + def test_delete_op_removes_an_item + client = signed_in_client + client.post_json('/api/items', ops: [{ op: 'set', key: 'board.7.1', value: 'x' }]) + client.post_json('/api/items', ops: [{ op: 'del', key: 'board.7.1' }]) + + assert_equal({}, json_body(client.get('/api/items'))) + end + + def test_malformed_json_is_a_400 + client = signed_in_client + response = client.post('/api/items', input: 'not json', 'CONTENT_TYPE' => 'application/json') + + assert_equal 400, response.status + assert_equal 'malformed JSON', json_body(response)['error'] + end + + def test_missing_ops_array_is_a_400 + assert_equal 400, signed_in_client.post_json('/api/items', {}).status + end + + def test_oversized_batch_is_refused + ops = Array.new(501) { |i| { op: 'set', key: "board.1.#{i}", value: 'x' } } + + assert_equal 400, signed_in_client.post_json('/api/items', ops: ops).status + assert_equal 0, ValidBoard::STORE.item_count + end + + def test_bad_key_is_a_400_and_writes_nothing + client = signed_in_client + response = client.post_json('/api/items', ops: [{ op: 'set', key: 'nullboard.../x', value: 'x' }]) + + assert_equal 400, response.status + assert_equal 0, ValidBoard::STORE.item_count + end + + def test_oversized_value_is_a_413 + response = signed_in_client.post_json('/api/items', + ops: [{ op: 'set', key: 'big', value: 'x' * (2 * 1024 * 1024 + 1) }]) + + assert_equal 413, response.status + end + + def test_wipe_clears_boards_but_leaves_you_signed_in + client = signed_in_client + client.post_json('/api/items', ops: [{ op: 'set', key: 'config', value: 'x' }]) + + assert_equal 200, client.delete('/api/items').status + assert_equal({}, json_body(client.get('/api/items'))) + assert ValidBoard::STORE.password_set?, 'wiping must not clear the password' + end + + # + # cross-site protection + # + + # rack-protection's HttpOrigin catches this. Sinatra configures every + # protection with reaction :drop_session rather than :deny, so the request + # arrives at the auth filter with an empty session and comes back 401 instead + # of 403 — both are a refusal, and which one it is isn't the point worth + # pinning down here. + def test_write_from_another_origin_is_rejected + client = signed_in_client + + response = client.post_json('/api/items', + { ops: [{ op: 'set', key: 'config', value: 'x' }] }, + 'HTTP_ORIGIN' => 'https://evil.example') + + assert_includes [401, 403], response.status + assert_equal 0, ValidBoard::STORE.item_count, 'a cross-site write must not reach the database' + end + + def test_write_from_our_own_origin_is_allowed + client = signed_in_client + + response = client.post_json('/api/items', + { ops: [{ op: 'set', key: 'config', value: 'x' }] }, + 'HTTP_ORIGIN' => 'http://example.org', 'HTTP_HOST' => 'example.org') + + assert_equal 200, response.status + end + + # + # first run + # + + def test_service_refuses_to_serve_until_a_password_is_set + without_password do + response = Client.new.get('/') + + assert_equal 503, response.status + assert_includes response.body, 'validboard-passwd' + end + end + + def test_health_check_still_answers_without_a_password + without_password do + assert_equal 200, Client.new.get('/healthz').status + end + end + + private + + def bootstrap_from(response) + json = response.body[/) + @store.apply([{ 'op' => 'set', 'key' => 'odd', 'value' => value }]) + + assert_equal value, @store.all_items['odd'] + end + + # + # validation — a whole batch is checked before any of it is written, so a + # bad op can't leave half a board save behind + # + + def test_rejects_key_with_illegal_characters + assert_raises(ValidBoard::Store::InvalidKey) do + @store.apply([{ 'op' => 'set', 'key' => '../../etc/passwd', 'value' => 'x' }]) + end + end + + def test_rejects_empty_key + assert_raises(ValidBoard::Store::InvalidKey) do + @store.apply([{ 'op' => 'set', 'key' => '', 'value' => 'x' }]) + end + end + + def test_rejects_unknown_op + assert_raises(ValidBoard::Store::InvalidKey) do + @store.apply([{ 'op' => 'truncate', 'key' => 'config' }]) + end + end + + def test_rejects_oversized_value + assert_raises(ValidBoard::Store::ValueTooBig) do + @store.apply([{ 'op' => 'set', 'key' => 'huge', 'value' => 'x' * (2 * 1024 * 1024 + 1) }]) + end + end + + def test_bad_op_leaves_the_rest_of_the_batch_unwritten + @store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'original' }]) + + assert_raises(ValidBoard::Store::InvalidKey) do + @store.apply([{ 'op' => 'set', 'key' => 'before', 'value' => 'changed' }, + { 'op' => 'set', 'key' => 'bad key!', 'value' => 'x' }]) + end + + assert_equal 'original', @store.all_items['before'] + assert_equal 1, @store.item_count + end + + # + # wipe + # + + def test_wipe_clears_items_but_keeps_the_password + @store.password = 'a-long-enough-password' + @store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'x' }]) + + @store.wipe_items! + + assert_equal({}, @store.all_items) + assert @store.password_set?, 'wiping boards must not lock you out' + end + + # + # password + # + + def test_no_password_until_one_is_set + refute @store.password_set? + refute @store.password_matches?('anything') + end + + def test_password_round_trip + @store.password = 'a-long-enough-password' + + assert @store.password_set? + assert @store.password_matches?('a-long-enough-password') + refute @store.password_matches?('a-long-enough-passwerd') + refute @store.password_matches?('') + end + + def test_password_is_hashed_not_stored + @store.password = 'a-long-enough-password' + + refute_includes @store.meta_get('password_hash'), 'a-long-enough-password' + assert_match(/\A\$2[aby]\$/, @store.meta_get('password_hash')) + end + + def test_short_password_is_refused + assert_raises(ArgumentError) { @store.password = 'short' } + end + + def test_password_can_be_changed + @store.password = 'the-first-password' + @store.password = 'the-second-password' + + refute @store.password_matches?('the-first-password') + assert @store.password_matches?('the-second-password') + end + + # + # session secret + # + + def test_session_secret_is_generated_once_and_kept + first = @store.session_secret + + assert_operator first.length, :>=, 64 + assert_equal first, @store.session_secret + assert_equal first, ValidBoard::Store.new(@path).session_secret, 'secret must survive a restart' + end + + # + # schema + # + + def test_reopening_an_existing_database_keeps_the_data + @store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => 'kept' }]) + + assert_equal 'kept', ValidBoard::Store.new(@path).all_items['config'] + end + + def test_refuses_a_database_from_a_newer_version + @store.meta_set('schema_version', ValidBoard::Store::SCHEMA_VERSION + 1) + + error = assert_raises(RuntimeError) { ValidBoard::Store.new(@path) } + assert_match(/newer than this code/, error.message) + end + + def test_database_file_is_not_world_readable + assert_equal '600', format('%o', File.stat(@path).mode & 0o777) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..5edbeb9 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require 'json' +require 'rack' +require 'rack/mock' + +# Point the app at a throwaway database *before* loading it — app.rb opens the +# store at require time. +TEST_DIR = Dir.mktmpdir('validboard-test') + +ENV['VALIDBOARD_DB'] = File.join(TEST_DIR, 'test.db') +ENV.delete('VALIDBOARD_SECRET') +ENV.delete('VALIDBOARD_SECURE_COOKIE') + +Minitest.after_run { FileUtils.remove_entry(TEST_DIR, true) } + +require_relative '../app' + +TEST_PASSWORD = 'correct-horse-battery' + +# A cookie-carrying client. Rack::MockRequest doesn't keep a jar of its own, so +# without this every request would look like a fresh browser and nothing that +# depends on a session could be tested. +class Client + COOKIE_NAME = 'validboard.session' + + def initialize(remote_addr: '127.0.0.1') + @mock = Rack::MockRequest.new(ValidBoard::App) + @cookies = {} + @remote_addr = remote_addr + end + + def get(path, env = {}) = request('GET', path, env) + def post(path, env = {}) = request('POST', path, env) + def delete(path, env = {}) = request('DELETE', path, env) + + def post_json(path, obj, env = {}) + post(path, env.merge(input: JSON.generate(obj), 'CONTENT_TYPE' => 'application/json')) + end + + def login(password = TEST_PASSWORD) + post('/login', params: { 'password' => password }) + end + + def session_cookie + @cookies[COOKIE_NAME] + end + + private + + def request(method, path, env) + # Rack::MockRequest leaves HTTP_VERSION unset, which reads as HTTP/1.0 — + # and Sinatra answers a POST redirect with 302 there but 303 over 1.1. Pin + # it so the tests see what a browser will. + env = { 'REMOTE_ADDR' => @remote_addr, 'HTTP_VERSION' => 'HTTP/1.1' }.merge(env) + env['HTTP_COOKIE'] = @cookies.map { |k, v| "#{k}=#{v}" }.join('; ') unless @cookies.empty? + + response = @mock.request(method, path, env) + absorb_cookies(response) + response + end + + def absorb_cookies(response) + raw = response.headers['Set-Cookie'] + return if raw.nil? + + Array(raw).flat_map { |header| header.split("\n") }.each do |line| + name, value = line.split(';', 2).first.to_s.split('=', 2) + next if name.nil? + + @cookies[name] = value.to_s + end + end +end + +module TestHelpers + # Puts the app's store back to a known state: one password, no boards. + def reset_app_store! + ValidBoard::STORE.wipe_items! + ValidBoard::STORE.password = TEST_PASSWORD + end + + def signed_in_client(**kwargs) + client = Client.new(**kwargs) + response = client.login + + raise "login failed: #{response.status}" unless response.status == 303 + + client + end + + def json_body(response) + JSON.parse(response.body) + end +end