# 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[/