Add /admin/users to create, edit, disable and delete accounts with the per-area permission grid. New users get a one-time invite link (48 h) by email; the admin also sees it once, so an account without email can still be onboarded. Forgotten passwords get a 1 h reset link with the same answer for unknown accounts. Only token hashes are stored. Guards against escalation and lockout: a user manager can only grant flags they hold and cannot touch flags they lack; only superusers change superuser accounts; nobody can disable or delete themselves; the last active superuser stays. Logins, user changes, and bingo and invoice actions are recorded in an audit log at /admin/audit. Read timestamps as UTC: they are all written with Time.now.utc, and on a UTC+4 host a fresh 1 h token read back as already expired. Assisted-by: Claude:opus-5
164 lines
6.4 KiB
Ruby
164 lines
6.4 KiB
Ruby
require_relative 'test_helper'
|
|
|
|
class UsersAdminTest < 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!
|
|
DB.connection[:audit_log].delete
|
|
Mail::TestMailer.deliveries.clear
|
|
end
|
|
|
|
def token(path = '/admin/users')
|
|
get path
|
|
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
end
|
|
|
|
def create_user(params)
|
|
post '/admin/users', { authenticity_token: token('/admin/users/new') }.merge(params)
|
|
end
|
|
|
|
def invite_url
|
|
Mail::TestMailer.deliveries.last.body.to_s[%r{https?://\S+/admin/invite/\S+}]
|
|
end
|
|
|
|
def test_superuser_creates_user_and_invite_is_emailed_and_shown
|
|
admin_login!
|
|
create_user(username: 'carol', email: 'carol@example.com', perm: { 'bingo' => { 'read' => '1', 'delete' => '1' } })
|
|
assert_equal 302, last_response.status
|
|
carol = User[username: 'carol']
|
|
assert carol.can?('bingo', 'read')
|
|
assert carol.can?('bingo', 'delete')
|
|
refute carol.can?('bingo', 'write')
|
|
assert_nil carol.password_digest
|
|
assert_equal ['carol@example.com'], Mail::TestMailer.deliveries.last.to
|
|
get '/admin/users'
|
|
assert_includes last_response.body, 'emailed to carol@example.com'
|
|
assert_includes last_response.body, '/admin/invite/'
|
|
get '/admin/users'
|
|
refute_includes last_response.body, '/admin/invite/' # shown once
|
|
end
|
|
|
|
def test_delete_implies_read
|
|
admin_login!
|
|
create_user(username: 'dave', perm: { 'bingo' => { 'delete' => '1' } })
|
|
assert User[username: 'dave'].can?('bingo', 'read')
|
|
end
|
|
|
|
def test_invite_accept_sets_password_and_is_single_use
|
|
admin_login!
|
|
create_user(username: 'carol', email: 'carol@example.com')
|
|
path = URI(invite_url).path
|
|
clear_cookies
|
|
get path
|
|
assert last_response.ok?
|
|
assert_includes last_response.body, 'carol'
|
|
tok = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post path, { authenticity_token: tok, password: 'short', confirm: 'short' }
|
|
assert_equal 422, last_response.status
|
|
post path, { authenticity_token: tok, password: 'carol-password-1', confirm: 'carol-password-1' }
|
|
assert_equal 302, last_response.status
|
|
assert User[username: 'carol'].authenticate('carol-password-1')
|
|
get '/admin'
|
|
assert last_response.ok? # logged in
|
|
clear_cookies
|
|
get path
|
|
assert_equal 404, last_response.status # used
|
|
end
|
|
|
|
def test_expired_invite_rejected
|
|
u = User.create(username: 'erin')
|
|
raw = UserToken.issue!(u, 'invite')
|
|
UserToken.where(user_id: u.id).update(expires_at: Time.now.utc - 1)
|
|
get "/admin/invite/#{raw}"
|
|
assert_equal 404, last_response.status
|
|
end
|
|
|
|
def test_reset_request_same_answer_and_mails_only_real_accounts
|
|
User.create(username: 'frank', email: 'frank@example.com', password: 'frank-password-1')
|
|
get '/admin/reset'
|
|
tok = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
|
post '/admin/reset', { authenticity_token: tok, login: 'nobody' }
|
|
nobody = last_response.body
|
|
post '/admin/reset', { authenticity_token: tok, login: 'frank@example.com' }
|
|
assert_equal nobody.scan(/reset link is on its way/), last_response.body.scan(/reset link is on its way/)
|
|
assert_equal 1, Mail::TestMailer.deliveries.size
|
|
url = Mail::TestMailer.deliveries.last.body.to_s[%r{https?://\S+/admin/reset/\S+}]
|
|
get URI(url).path
|
|
assert last_response.ok?, "#{url.inspect} -> #{last_response.status} #{last_response.body[0, 200]}"
|
|
end
|
|
|
|
def test_no_escalation_by_user_manager
|
|
boss = User.create(username: 'manager', password: 'manager-pass-1')
|
|
UserPermission.create(user_id: boss.id, area: 'users', can_read: true, can_write: true)
|
|
UserPermission.create(user_id: boss.id, area: 'bingo', can_read: true)
|
|
target = User.create(username: 'grace', password: 'grace-password-1')
|
|
UserPermission.create(user_id: target.id, area: 'invoices', can_read: true)
|
|
admin_login!(username: 'manager', password: 'manager-pass-1')
|
|
|
|
create_user(username: 'henry', superuser: '1', perm: { 'invoices' => { 'write' => '1' }, 'bingo' => { 'read' => '1' } })
|
|
henry = User[username: 'henry']
|
|
refute henry.superuser
|
|
refute henry.can?('invoices', 'write') # manager lacks it
|
|
assert henry.can?('bingo', 'read')
|
|
|
|
post "/admin/users/#{target.id}", { authenticity_token: token, name: 'G', active: '1', perm: { 'bingo' => { 'read' => '1' } } }
|
|
target.refresh
|
|
assert target.can?('invoices', 'read') # kept: manager can't touch it
|
|
assert target.can?('bingo', 'read')
|
|
|
|
root = User.create(username: 'root', superuser: true, password: 'root-password-11')
|
|
post "/admin/users/#{root.id}", { authenticity_token: token, name: 'x', active: '' }
|
|
assert_equal 403, last_response.status
|
|
end
|
|
|
|
def test_cannot_delete_self_or_last_superuser
|
|
admin_login!
|
|
me = User[username: ENV['ADMIN_USER']]
|
|
post "/admin/users/#{me.id}/delete", { authenticity_token: token }
|
|
assert_equal 422, last_response.status
|
|
other = User.create(username: 'other', superuser: true, password: 'other-password-1')
|
|
post "/admin/users/#{other.id}/delete", { authenticity_token: token }
|
|
assert_equal 302, last_response.status
|
|
assert_nil User[other.id]
|
|
end
|
|
|
|
def test_disabling_logs_user_out
|
|
admin_login!
|
|
u = User.create(username: 'ivan', password: 'ivan-password-12')
|
|
v = u.session_version
|
|
post "/admin/users/#{u.id}", { authenticity_token: token, name: '', email: '' } # active unticked
|
|
u.refresh
|
|
refute u.active
|
|
assert_equal v + 1, u.session_version
|
|
end
|
|
|
|
def test_audit_log_records_and_lists
|
|
admin_login!
|
|
create_user(username: 'judy')
|
|
actions = DB.connection[:audit_log].select_map(:action)
|
|
assert_includes actions, 'auth.login'
|
|
assert_includes actions, 'user.create'
|
|
get '/admin/audit'
|
|
assert last_response.ok?
|
|
assert_includes last_response.body, 'user.create'
|
|
assert_includes last_response.body, 'judy'
|
|
end
|
|
|
|
def test_users_read_only_cannot_create
|
|
u = User.create(username: 'viewer', password: 'viewer-password1')
|
|
UserPermission.create(user_id: u.id, area: 'users', can_read: true)
|
|
admin_login!(username: 'viewer', password: 'viewer-password1')
|
|
get '/admin/users'
|
|
assert last_response.ok?
|
|
refute_includes last_response.body, '+ New user'
|
|
get '/admin/users/new'
|
|
assert_equal 403, last_response.status
|
|
end
|
|
end
|