# 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 tag' })
ValidBoard::STORE.apply([{ 'op' => 'set', 'key' => 'board.1.1', 'value' => payload }])
response = signed_in_client.get('/')
script = response.body[/ in the block must be the one that closes it.
assert_equal 1, script.scan(%r{}).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[/