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