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
+292
View File
@@ -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 </script> tag' })
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'board.1.1', 'value' => payload }])
response = signed_in_client.get('/')
script = response.body[/<script type="application\/json" id="nb-bootstrap">.*?<\/script>/m]
# The only </script> in the block must be the one that closes it.
assert_equal 1, script.scan(%r{</script>}).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[/<script type="application\/json" id="nb-bootstrap">(.*?)<\/script>/m, 1]
refute_nil json, 'page had no bootstrap block'
JSON.parse(json)
end
# Reaches around the Store to unset the password, since nothing in the app is
# allowed to do that.
def without_password
hash = ValidBoard::STORE.meta_get('password_hash')
db = SQLite3::Database.new(ValidBoard::STORE.path)
db.execute("DELETE FROM meta WHERE key = 'password_hash'")
db.close
yield
ensure
ValidBoard::STORE.meta_set('password_hash', hash) if hash
end
end
+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
+98
View File
@@ -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