Add tests for the store and the API

Rack::MockRequest with a cookie jar rather than rack-test, so the suite
needs nothing beyond the Debian packages the app already uses.

Covers key validation and batch atomicity, password hashing, the schema
version guard, auth and lockout, the storage API, and that a note
containing a closing script tag can't break out of the bootstrap block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
This commit is contained in:
Tom Flux
2026-08-14 22:41:11 +01:00
co-authored by Claude Opus 5
parent 7e2f280acc
commit d94984cd97
4 changed files with 598 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
# frozen_string_literal: true
require_relative 'test_helper'
class StoreTest < Minitest::Test
def setup
@path = File.join(TEST_DIR, "store-#{name}-#{object_id}.db")
@store = ValidBoard::Store.new(@path)
end
def teardown
FileUtils.rm_f(Dir.glob("#{@path}*"))
end
#
# items
#
def test_starts_empty
assert_equal({}, @store.all_items)
assert_equal 0, @store.item_count
end
def test_set_and_read_back
@store.apply([{ 'op' => 'set', 'key' => 'config', 'value' => '{"theme":"dark"}' }])
assert_equal({ 'config' => '{"theme":"dark"}' }, @store.all_items)
end
def test_set_overwrites_existing_key
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'first' }])
@store.apply([{ 'op' => 'set', 'key' => 'board.1.meta', 'value' => 'second' }])
assert_equal 'second', @store.all_items['board.1.meta']
assert_equal 1, @store.item_count
end
def test_delete_removes_key
@store.apply([{ 'op' => 'set', 'key' => 'board.1.7', 'value' => 'x' }])
@store.apply([{ 'op' => 'del', 'key' => 'board.1.7' }])
assert_equal({}, @store.all_items)
end
def test_deleting_a_missing_key_is_not_an_error
assert_equal 1, @store.apply([{ 'op' => 'del', 'key' => 'never.existed' }])
end
def test_mixed_batch_applies_in_order
@store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'a' },
{ 'op' => 'set', 'key' => 'drop', 'value' => 'b' }])
applied = @store.apply([{ 'op' => 'set', 'key' => 'keep', 'value' => 'c' },
{ 'op' => 'del', 'key' => 'drop' },
{ 'op' => 'set', 'key' => 'fresh', 'value' => 'd' }])
assert_equal 3, applied
assert_equal({ 'keep' => 'c', 'fresh' => 'd' }, @store.all_items)
end
def test_values_survive_awkward_characters
value = %(unicode ✓ / quote " / backslash \\ / newline \n / tag </script>)
@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