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:
co-authored by
Claude Opus 5
parent
7e2f280acc
commit
d94984cd97
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rake/testtask'
|
||||
|
||||
Rake::TestTask.new(:test) do |t|
|
||||
t.libs << 'test'
|
||||
t.test_files = FileList['test/*_test.rb']
|
||||
t.warning = false
|
||||
end
|
||||
|
||||
desc 'Run the app locally on http://127.0.0.1:8047'
|
||||
task :server do
|
||||
sh 'puma --bind tcp://127.0.0.1:8047 config.ru'
|
||||
end
|
||||
|
||||
task default: :test
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user