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
193 lines
5.5 KiB
Ruby
193 lines
5.5 KiB
Ruby
# 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
|