Files
ValidBoard/test/app_test.rb
T
Tom FluxandClaude Opus 5 d94984cd97 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
2026-08-14 22:41:11 +01:00

293 lines
8.8 KiB
Ruby

# 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