1 Commits

Author SHA1 Message Date
0bd77bc37a WIP Add service accounts and ACIs
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-28 10:57:12 +04:00
21 changed files with 68 additions and 374 deletions

View File

@@ -11,8 +11,6 @@ DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api'
MASTODON_PUBLIC_URL='http://example.social'
LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'

View File

@@ -3,7 +3,7 @@ class Services::ChatController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.service_enabled?(:xmpp)
@service_enabled = current_user.services_enabled.include?(:xmpp)
end
private

View File

@@ -3,7 +3,7 @@ class Services::MastodonController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.service_enabled?(:mastodon)
@service_enabled = current_user.services_enabled.include?(:mastodon)
end
private

View File

@@ -5,7 +5,7 @@ class Services::RemotestorageController < Services::BaseController
# Dashboard
def show
# unless current_user.service_enabled?(:remotestorage)
# unless current_user.services_enabled.include?(:remotestorage)
# redirect_to service_remotestorage_info_path
# end
@rs_auths = current_user.remote_storage_authorizations

View File

@@ -7,15 +7,14 @@ class WebfingerController < ApplicationController
resource = params[:resource]
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
@username, @domain = @useraddress.split("@")
@username, @org = @useraddress.split("@")
unless Rails.env.development?
# Allow different domains (e.g. localhost:3000) in development only
head 404 and return unless @domain == Setting.primary_domain
head 404 and return unless @org == Setting.primary_domain
end
unless @user = User.where(ou: Setting.primary_domain)
.find_by(cn: @username.downcase)
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
head 404 and return
end
@@ -29,50 +28,12 @@ class WebfingerController < ApplicationController
private
def webfinger
jrd = {
subject: "acct:#{@user.address}",
aliases: [],
links: []
}
links = [];
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
# https://docs.joinmastodon.org/spec/webfinger/
jrd[:aliases] += mastodon_aliases
jrd[:links] += mastodon_links
end
# TODO check if storage service is enabled for user, not just globally
links << remotestorage_link if Setting.remotestorage_enabled
if Setting.remotestorage_enabled && @user.service_enabled?(:remotestorage)
# https://datatracker.ietf.org/doc/draft-dejong-remotestorage/
jrd[:links] << remotestorage_link
end
jrd
end
def mastodon_aliases
[
"#{Setting.mastodon_public_url}/@#{@user.cn}",
"#{Setting.mastodon_public_url}/users/#{@user.cn}"
]
end
def mastodon_links
[
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: "#{Setting.mastodon_public_url}/@#{@user.cn}"
},
{
rel: "self",
type: "application/activity+json",
href: "#{Setting.mastodon_public_url}/users/#{@user.cn}"
},
{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: "#{Setting.mastodon_public_url}/authorize_interaction?uri={uri}"
}
]
{ "links" => links }
end
def remotestorage_link
@@ -80,9 +41,9 @@ class WebfingerController < ApplicationController
storage_url = "#{Setting.rs_storage_url}/#{@username}"
{
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
href: storage_url,
properties: {
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",
"href" => storage_url,
"properties" => {
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter

View File

@@ -1,7 +1,7 @@
class CreateLdapUserJob < ApplicationJob
queue_as :default
def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
def perform(username, domain, email, hashed_pw)
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
attr = {
objectclass: ["top", "account", "person", "extensibleObject"],
@@ -12,10 +12,6 @@ class CreateLdapUserJob < ApplicationJob
userPassword: hashed_pw
}
if confirmed
attr[:serviceEnabled] = Setting.default_services
end
ldap_client.add(dn: dn, attributes: attr)
end

View File

@@ -2,8 +2,8 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
return unless inviter.service_enabled?(:xmpp) &&
invitee.service_enabled?(:xmpp) &&
return unless inviter.services_enabled.include?("xmpp") &&
invitee.services_enabled.include?("xmpp") &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new

View File

@@ -206,9 +206,4 @@ class Setting < RailsSettings::Base
#
# field :email_imap_port, type: :string,
# default: ENV["EMAIL_IMAP_PORT"].presence || 993
def self.default_services
# TODO Make configurable from respective service settings page
%w[ discourse gitea mastodon mediawiki xmpp ]
end
end

View File

@@ -93,7 +93,9 @@ class User < ApplicationRecord
LdapManager::UpdateEmail.call(dn: self.dn, address: self.email)
else
# E-Mail from signup confirmed (i.e. account activation)
enable_default_services
# TODO Make configurable, only activate globally enabled services
enable_service %w[ discourse gitea mediawiki xmpp ]
# TODO enable in development when we have easy setup of ejabberd etc.
return if Rails.env.development? || !Setting.ejabberd_enabled?
@@ -131,7 +133,7 @@ class User < ApplicationRecord
def mastodon_address
return nil unless Setting.mastodon_enabled?
"#{self.cn.gsub("-", "_")}@#{Setting.mastodon_address_domain}"
"#{self.cn}@#{Setting.mastodon_address_domain}"
end
def valid_attribute?(attribute_name)
@@ -139,10 +141,6 @@ class User < ApplicationRecord
self.errors[attribute_name].blank?
end
def enable_default_services
enable_service Setting.default_services
end
def ln_create_invoice(payload)
lndhub = Lndhub.new
lndhub.authenticate self
@@ -180,10 +178,6 @@ class User < ApplicationRecord
ldap_entry[:services_enabled] || []
end
def service_enabled?(name)
services_enabled.map(&:to_sym).include?(name.to_sym)
end
def enable_service(service)
current_services = services_enabled
new_services = Array(service).map(&:to_s)

View File

@@ -35,15 +35,11 @@ class CreateAccount < ApplicationService
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
# TODO move to confirmation
# (and/or add email_confirmed to entry and use in login filter)
def add_ldap_document
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
CreateLdapUserJob.perform_later(
username: @username,
domain: @domain,
email: @email,
hashed_pw: hashed_pw,
confirmed: @confirmed
)
CreateLdapUserJob.perform_later(@username, @domain, @email, hashed_pw)
end
def create_lndhub_account(user)

View File

@@ -16,8 +16,8 @@
<p>
There's something to do for everyone, especially non-programmers! For
example, we need more help with graphics, UI/UX design, and
content/copywriting. Also, testing any of our software and reporting
issues you encounter along the way is very valuable.
content/copywriting. We also need moderators for social media. And beta
testers for our software. The list doesn't end there.
</p>
<p>
A good way to get started is to join one of our
@@ -43,7 +43,7 @@
</p>
<p>
We have run two 6-month trials so far, with the next trial period
starting sometime in Q2 2024. Watch your email for notifications about it!
starting sometime in Q1 2024. Watch your email for notifications about it!
</p>
</section>
<% end %>

View File

@@ -98,17 +98,7 @@
description: "The official Web app",
icon_path: "/img/logos/icon_mastodon-2.svg",
links: [
["Launch", "https://kosmos.social"],
["GitHub", "https://github.com/mastodon/mastodon"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
["Launch", "https://kosmos.social"]
]
) %>
<%= render AppInfoComponent.new(
@@ -160,15 +150,6 @@
["Google Play", "https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(
@@ -199,15 +180,6 @@
["App Store", "https://apps.apple.com/app/mammoth-for-mastodon/id1667573899"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(

View File

@@ -2,4 +2,3 @@
:queues:
- default
- mailers
- remotestorage

View File

@@ -19,6 +19,18 @@ namespace :ldap do
}, true
end
# TODO
desc "Add application account to directory"
task add_application_account: :environment do |t, args|
# Add uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org with userPassword
end
# TODO
desc "Add application ACI/permissions for OU, i.e. read/search users"
task add_application_account: :environment do |t, args|
# (target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
end
desc "Add custom attributes to schema"
task add_custom_attributes: :environment do |t, args|
%w[ admin service_enabled nostr_key ].each do |name|

View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="54"
height="54"
viewBox="0 0 54 54"
version="1.1"
xml:space="preserve"
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"
id="svg4"
sodipodi:docname="icon_phanpy.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs4" /><sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.359375"
inkscape:cx="26.192932"
inkscape:cy="24.542502"
inkscape:window-width="2160"
inkscape:window-height="1281"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<rect
id="Logo-simple"
serif:id="Logo simple"
x="0"
y="0"
width="63.993999"
height="63.993999"
style="fill:none" />
<g
id="Logo-simple1"
serif:id="Logo simple"
transform="translate(-5.123639,-4.9968626)">
<g
id="g4">
<path
d="m 37.774,11.471 c 14.639,3.752 19.034,16.557 15.889,31.304 -0.696,3.261 -2.563,6.661 -6.356,8.693 -3.204,1.717 -8.07,2.537 -15.338,0.55 0,0 -9.634,-2.404 -9.634,-2.404 C 11.651,46.992 8.378,38.733 10.027,31.823 13.654,16.622 25.57,8.343 37.774,11.471 Z"
style="fill:#a4bff7"
id="path1" />
<path
d="m 36.76,15.429 c 12.289,3.15 15.547,14.114 12.907,26.493 -0.947,4.44 -4.937,9.365 -16.664,6.143 L 23.319,45.648 C 15.465,43.725 12.789,37.848 14.001,32.771 17.017,20.132 26.612,12.828 36.76,15.429 Z"
style="fill:#d8e7fe"
id="path2" />
<path
d="m 27.471,24.991 c -1.457,-0.698 -7.229,3.213 -7.663,8.926 -0.182,2.39 4.55,3.237 5.071,-0.169 0.725,-4.743 3.715,-8.218 2.592,-8.757 z"
style="fill:#6081e6"
id="path3" />
<path
d="m 38.217,26.996 c -2.083,0.327 -0.382,5.901 -0.595,10.727 -0.123,2.8 4.388,3.464 4.703,2.011 1.098,-5.073 -2.066,-13.058 -4.108,-12.738 z"
style="fill:#6081e6"
id="path4" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

4
schemas/ldap/aci.ldif Normal file
View File

@@ -0,0 +1,4 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
add: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || serviceEnabled || displayName || jpegPhoto || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

View File

@@ -0,0 +1,4 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
delete: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

View File

@@ -3,24 +3,12 @@ require 'rails_helper'
RSpec.describe CreateLdapUserJob, type: :job do
let(:ldap_client_mock) { instance_double(Net::LDAP) }
before do
allow_any_instance_of(described_class).to receive(:ldap_client).and_return(ldap_client_mock)
end
subject(:job) {
described_class.perform_later(
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november'
)
}
allow_any_instance_of(described_class).to receive(:ldap_client).and_return(ldap_client_mock)
subject(:job_for_preconfirmed_account) {
described_class.perform_later(
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november',
confirmed: true
'halfinney', 'kosmos.org', 'halfinney@example.com',
'remember-remember-the-5th-of-november'
)
}
@@ -42,26 +30,6 @@ RSpec.describe CreateLdapUserJob, type: :job do
)
end
it "adds default services for pre-confirmed accounts" do
allow(ldap_client_mock).to receive(:add) # spy on mock
allow(Setting).to receive(:default_services).and_return(["xmpp", "discourse"])
perform_enqueued_jobs { job_for_preconfirmed_account }
expect(ldap_client_mock).to have_received(:add).with(
dn: "cn=halfinney,ou=kosmos.org,cn=users,dc=kosmos,dc=org",
attributes: {
objectclass: ["top", "account", "person", "extensibleObject"],
cn: "halfinney",
sn: "halfinney",
uid: "halfinney",
mail: "halfinney@example.com",
serviceEnabled: ["xmpp", "discourse"],
userPassword: "remember-remember-the-5th-of-november"
}
)
end
after do
clear_enqueued_jobs
clear_performed_jobs

View File

@@ -16,10 +16,6 @@ RSpec.describe User, type: :model do
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
context "Mastodon service not configured" do
before do
Setting.mastodon_enabled = false
end
it "returns nil" do
expect(user.mastodon_address).to be_nil
end
@@ -45,14 +41,6 @@ RSpec.describe User, type: :model do
expect(user.mastodon_address).to eq("jimmy@kosmos.social")
end
end
describe "username contains hyphen/dash" do
let(:jammy) { build :user, cn: "jammy-jellyfish", ou: "kosmos.org" }
it "returns the user address" do
expect(jammy.mastodon_address).to eq("jammy_jellyfish@kosmos.org")
end
end
end
end
@@ -84,25 +72,6 @@ RSpec.describe User, type: :model do
end
end
describe "#service_enabled?" do
before do
allow(user).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["gitea", "xmpp"]
})
end
it "returns true or false" do
expect(user.service_enabled?("gitea")).to be(true)
expect(user.service_enabled?("email")).to be(false)
end
it "returns false when service is not enabled" do
expect(user.service_enabled?(:gitea)).to be(true)
expect(user.service_enabled?(:email)).to be(false)
end
end
describe "#enable_service" do
before do
allow(user).to receive(:ldap_entry).and_return({
@@ -178,7 +147,7 @@ RSpec.describe User, type: :model do
after { clear_enqueued_jobs }
it "enables default services" do
expect(user).to receive(:enable_service).with(%w[ discourse gitea mastodon mediawiki xmpp ])
expect(user).to receive(:enable_service).with(%w[ discourse gitea mediawiki xmpp ])
user.send :devise_after_confirmation
end

View File

@@ -1,87 +1,13 @@
require 'rails_helper'
RSpec.describe "WebFinger", type: :request do
describe "User does not exist" do
it "returns a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
context "User exists" do
let(:user) { create :user, cn: 'tony', ou: 'kosmos.org' }
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["mastodon", "remotestorage"]
})
end
describe "Mastodon entries" do
context "Mastodon available" do
it "includes the Mastodon aliases and links for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).to include("http://example.social/@tony")
expect(res["aliases"]).to include("http://example.social/users/tony")
profile_link = res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}
self_link = res["links"].find{|l| l["rel"] == "self"}
ostatus_link = res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}
expect(profile_link["type"]).to eql("text/html")
expect(profile_link["href"]).to eql("http://example.social/@tony")
expect(self_link["type"]).to eql("application/activity+json")
expect(self_link["href"]).to eql("http://example.social/users/tony")
expect(ostatus_link["template"]).to eql("http://example.social/authorize_interaction?uri={uri}")
end
describe "remoteStorage link relation" do
context "user exists" do
before do
create :user, cn: 'tony', ou: 'kosmos.org'
end
context "Mastodon not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
context "Mastodon not available" do
before do
Setting.mastodon_enabled = false
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
end
describe "remoteStorage entries" do
context "remoteStorage available" do
context "remoteStorage enabled globally" do
it "includes the remoteStorage link for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
@@ -96,25 +22,6 @@ RSpec.describe "WebFinger", type: :request do
end
end
context "remoteStorage not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include the remoteStorage link" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"}
expect(rs_link).to be_nil
end
end
context "remoteStorage not available" do
before do
Setting.remotestorage_enabled = false
@@ -131,5 +38,12 @@ RSpec.describe "WebFinger", type: :request do
end
end
end
context "user does not exist" do
it "does return a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -53,32 +53,11 @@ RSpec.describe CreateAccount, type: :model do
expect(enqueued_jobs.size).to eq(1)
args = enqueued_jobs.first['arguments'][0]
expect(args["username"]).to eq('halfinney')
expect(args["domain"]).to eq('kosmos.org')
expect(args["email"]).to eq('halfinney@example.com')
expect(args["hashed_pw"]).to match(/^{SSHA512}.{171}=/)
end
after do
clear_enqueued_jobs
end
end
describe "#add_ldap_document for pre-confirmed account" do
include ActiveJob::TestHelper
let(:service) { CreateAccount.new(account: {
username: 'halfinney',
email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november',
confirmed: true
})}
it "enqueues a job to create the LDAP user document" do
service.send(:add_ldap_document)
args = enqueued_jobs.first['arguments'][0]
expect(args["confirmed"]).to be(true)
args = enqueued_jobs.first['arguments']
expect(args[0]).to eq('halfinney')
expect(args[1]).to eq('kosmos.org')
expect(args[2]).to eq('halfinney@example.com')
expect(args[3]).to match(/^{SSHA512}.{171}=/)
end
after do