Opening /admin/ logged out stored next=/admin/ in the login form, and a successful login then redirected to /admin/, which has no route (404). Redirect trailing-slash admin URLs to the slashless route with a 301, and drop trailing slashes from the post-login target too. Assisted-by: Claude:opus-5-5
143 lines
5.3 KiB
Ruby
143 lines
5.3 KiB
Ruby
require_relative 'test_helper'
|
|
|
|
class AdminAuthTest < Minitest::Test
|
|
include Rack::Test::Methods
|
|
include AdminLogin
|
|
|
|
def app
|
|
AsxpioWeb
|
|
end
|
|
|
|
def setup
|
|
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
|
TestDb.clean_users!
|
|
end
|
|
|
|
def login(username, password)
|
|
get '/admin/login'
|
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/login', { authenticity_token: token, username: username, password: password }
|
|
end
|
|
|
|
def test_password_hashing
|
|
d = User.hash_password('correct horse')
|
|
assert_match(/\Ascrypt\$\d+\$8\$1\$/, d)
|
|
assert User.verify_password('correct horse', d)
|
|
refute User.verify_password('wrong', d)
|
|
refute_equal d, User.hash_password('correct horse') # salted
|
|
end
|
|
|
|
def test_bootstrap_only_once
|
|
User.bootstrap!('Boss', 'first-password-1')
|
|
assert User[username: 'boss'].superuser
|
|
User.bootstrap!('other', 'x' * 12)
|
|
assert_equal 1, User.count
|
|
end
|
|
|
|
def test_login_and_logout
|
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
login('alice', 'alice-password-1')
|
|
assert_equal 302, last_response.status
|
|
get '/admin'
|
|
assert last_response.ok?
|
|
assert_includes last_response.body, 'alice'
|
|
get '/admin'
|
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/logout', { authenticity_token: token }
|
|
get '/admin'
|
|
assert_equal 302, last_response.status
|
|
end
|
|
|
|
def test_trailing_slash_admin_urls
|
|
get '/admin/'
|
|
assert_equal 301, last_response.status
|
|
assert_match %r{/admin\z}, last_response.location
|
|
get '/admin/bingo/?x=1'
|
|
assert_match %r{/admin/bingo\?x=1\z}, last_response.location
|
|
|
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
get '/admin/login?next=/admin/'
|
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/login', { authenticity_token: token, username: 'alice', password: 'alice-password-1', next: '/admin/' }
|
|
assert_match %r{/admin\z}, last_response.location
|
|
end
|
|
|
|
def test_wrong_password_and_unknown_user
|
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
login('alice', 'nope')
|
|
assert_equal 401, last_response.status
|
|
login('mallory', 'alice-password-1')
|
|
assert_equal 401, last_response.status
|
|
end
|
|
|
|
def test_inactive_user_cannot_log_in
|
|
User.create(username: 'alice', superuser: true, active: false, password: 'alice-password-1')
|
|
User.create(username: 'root', superuser: true, password: 'root-password-11')
|
|
login('alice', 'alice-password-1')
|
|
assert_equal 401, last_response.status
|
|
end
|
|
|
|
def test_env_credentials_only_without_active_superuser
|
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
login(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
|
assert_equal 401, last_response.status # dormant: alice exists
|
|
|
|
User[username: 'alice'].update(active: false)
|
|
login(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
|
assert_equal 302, last_response.status # recovery
|
|
assert User[username: ENV['ADMIN_USER']].superuser
|
|
end
|
|
|
|
def test_session_revoked_by_version_bump
|
|
u = User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
login('alice', 'alice-password-1')
|
|
get '/admin'
|
|
assert last_response.ok?
|
|
u.logout_everywhere!
|
|
get '/admin'
|
|
assert_equal 302, last_response.status
|
|
end
|
|
|
|
def test_user_without_permissions_limited_to_open_pages
|
|
User.create(username: 'bob', password: 'bob-password-12')
|
|
login('bob', 'bob-password-12')
|
|
get '/admin'
|
|
assert last_response.ok?
|
|
get '/admin/account/password'
|
|
assert last_response.ok?
|
|
get '/admin/invoices'
|
|
assert_equal 403, last_response.status
|
|
end
|
|
|
|
def test_must_change_password_flow
|
|
User.create(username: 'alice', superuser: true, must_change_password: true, password: 'temporary-pass-1')
|
|
login('alice', 'temporary-pass-1')
|
|
get '/admin/invoices'
|
|
assert_equal '/admin/account/password', URI(last_response.location).path
|
|
get '/admin/account/password'
|
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/account/password', { authenticity_token: token, current: 'temporary-pass-1', password: 'short', confirm: 'short' }
|
|
assert_equal 422, last_response.status
|
|
post '/admin/account/password', { authenticity_token: token, current: 'temporary-pass-1',
|
|
password: 'brand-new-pass-1', confirm: 'brand-new-pass-1' }
|
|
assert_equal 302, last_response.status
|
|
refute User[username: 'alice'].must_change_password
|
|
assert User[username: 'alice'].authenticate('brand-new-pass-1')
|
|
end
|
|
|
|
def test_next_param_is_restricted_to_admin
|
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
|
get '/admin/login?next=https://evil.example/'
|
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/login', { authenticity_token: token, username: 'alice', password: 'alice-password-1', next: 'https://evil.example/' }
|
|
assert_equal '/admin', URI(last_response.location).path
|
|
end
|
|
|
|
def test_login_rate_limited
|
|
AdminAuth.login_limit = RateLimit.new(limit: 2, window: 900)
|
|
3.times { login('x', 'y') }
|
|
assert_equal 429, last_response.status
|
|
ensure
|
|
AdminAuth.login_limit = nil
|
|
end
|
|
end
|