# 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 ) @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