Legacy support removals, Part I

pull/1129/head
Rick Carlino 2019-03-11 17:54:39 -05:00
parent 01f3d03dbe
commit 1b8b87b58b
9 changed files with 155 additions and 268 deletions

View File

@ -6,11 +6,15 @@ module Api
class AbstractController < ApplicationController
# This error is thrown when you try to use a non-JSON request body on an
# endpoint that requires JSON.
class OnlyJson < Exception; end;
CONSENT_REQUIRED = \
class OnlyJson < Exception; end
CONSENT_REQUIRED =
"all device users must agree to terms of service."
NOT_JSON = "That request was not valid JSON. Consider checking the"\
" request body with a JSON validator.."
NOT_JSON = "That request was not valid JSON. Consider checking the" \
" request body with a JSON validator.."
NULL = Gem::Version.new("0.0.0")
NOT_FBOS = Gem::Version.new("999.999.999")
respond_to :json
before_action :check_fbos_version
before_action :set_default_stuff
@ -50,11 +54,11 @@ module Api
end
rescue_from ActiveRecord::RecordInvalid do |exc|
render json: {error: exc.message}, status: 422
render json: { error: exc.message }, status: 422
end
rescue_from Errors::LegalConsent do |exc|
render json: {error: CONSENT_REQUIRED}, status: 451
render json: { error: CONSENT_REQUIRED }, status: 451
end
rescue_from ActiveModel::RangeError do |_|
@ -63,10 +67,10 @@ module Api
end
def default_serializer_options
{root: false, user: current_user}
{ root: false, user: current_user }
end
private
private
def clean_expired_farm_events
FarmEvents::CleanExpired.run!(device: current_device)
@ -76,7 +80,7 @@ private
# Our API does not do things the "Rails way" (we use Mutations for input
# sanitation) so we can ignore this and grab the raw input.
def raw_json
@raw_json ||= JSON.parse(request.body.read).tap{ |x| symbolize(x) }
@raw_json ||= JSON.parse(request.body.read).tap { |x| symbolize(x) }
rescue JSON::ParserError
raise OnlyJson
end
@ -92,9 +96,9 @@ private
REQ_ID = "X-Farmbot-Rpc-Id"
def set_default_stuff
request.format = "json"
id = request.headers[REQ_ID] || SecureRandom.uuid
response.headers[REQ_ID] = id
request.format = "json"
id = request.headers[REQ_ID] || SecureRandom.uuid
response.headers[REQ_ID] = id
# # IMPORTANT: We need to hoist X-Farmbot-Rpc-Id to a global so that it is
# # accessible for use with auto_sync.
Transport.current.set_current_request_id(response.headers[REQ_ID])
@ -112,7 +116,7 @@ private
def authenticate_user!
# All possible information that could be needed for any of the 3 auth
# strategies.
context = { jwt: request.headers["Authorization"],
context = { jwt: request.headers["Authorization"],
user: current_user }
# Returns a symbol representing the appropriate auth strategy, or nil if
# unknown.
@ -132,12 +136,12 @@ private
mark_as_seen
rescue Mutations::ValidationException => e
errors = e.errors.message.merge(strategy: strategy)
render json: {error: errors}, status: 401
render json: { error: errors }, status: 401
end
def auth_err
sorry("You failed to authenticate with the API. Ensure that you " \
" provide a JSON Web Token in the `Authorization:` header." , 401)
" provide a JSON Web Token in the `Authorization:` header.", 401)
end
def sorry(msg, status)
@ -153,7 +157,7 @@ private
end
def bad_version
render json: {error: "Upgrade to latest FarmBot OS"}, status: 426
render json: { error: "Upgrade to latest FarmBot OS" }, status: 426
end
EXPECTED_VER = Gem::Version::new GlobalConfig.dump["MINIMUM_FBOS_VERSION"]
@ -165,7 +169,7 @@ private
# Attempt 1:
# The device is using an HTTP client that does not provide a user-agent.
# We will assume this is an old FBOS version and set it to 0.0.0
return CalculateUpgrade::NULL if ua == FbosDetector::NO_UA_FOUND
return NULL if ua == FbosDetector::NO_UA_FOUND
# Attempt 2:
# If the user agent was missing, we would have returned by now.
@ -176,8 +180,8 @@ private
end
# Attempt 3:
# Pass CalculateUpgrade::NOT_FBOS if all other attempts fail.
return CalculateUpgrade::NOT_FBOS
# Pass NOT_FBOS if all other attempts fail.
return NOT_FBOS
end
# This is how we lock old versions of FBOS out of the API:
@ -197,10 +201,10 @@ private
def mark_as_seen(bot = (current_user && current_user.device))
when_farmbot_os do
if bot
v = fbos_version
v = fbos_version
bot.last_saw_api = Time.now
# Do _not_ set the FBOS version to 0.0.0 if the UA header is missing.
bot.fbos_version = v.to_s if v != CalculateUpgrade::NULL
bot.fbos_version = v.to_s if v != NULL
bot.save!
end
end

View File

@ -1,31 +0,0 @@
class CalculateUpgrade < Mutations::Command
NULL = Gem::Version.new("0.0.0")
NOT_FBOS = Gem::Version.new("999.999.999")
MEDIUM_OLDISH = Gem::Version.new("5.0.9")
LEGACY_CUTOFF = Gem::Version.new("5.0.6")
# For extremely old versions:
OLD_OS_URL = "https://api.github.com/repos/farmbot/farmbot_os" +
"/releases/8772352"
# For versions that are slightly out of date:
MID_OS_URL = "https://api.github.com/repos/FarmBot/farmbot_os/releases" +
"/9200943"
# Latest version when you don't have a custom release URL.
DEFAULT_OS = "https://api.github.com/repos/farmbot/farmbot_os/releases" +
"/latest"
# Custom URL, or fallback to default if no custom URL is set.
OS_RELEASE = ENV.fetch("OS_UPDATE_SERVER") { DEFAULT_OS }
required do
model :version, class: Gem::Version
end
def execute
return OLD_OS_URL if version <= LEGACY_CUTOFF
return MID_OS_URL if version <= MEDIUM_OLDISH
return OS_RELEASE
end
end

View File

@ -1,51 +1,53 @@
# Generates a JSON Web Token (JWT) for a given user. Typically placed in the
# `Authorization` header, or used a password to gain access to the MQTT server.
class SessionToken < AbstractJwtToken
MUST_VERIFY = "Verify account first"
MQTT = ENV.fetch("MQTT_HOST")
MUST_VERIFY = "Verify account first"
MQTT = ENV.fetch("MQTT_HOST")
# No beta URL provided? Then provide the latest stable.
DEFAULT_BETA_URL = \
DEFAULT_BETA_URL =
"https://api.github.com/repos/FarmBot/farmbot_os/releases/latest"
# If you are not using the standard MQTT broker (eg: you use a 3rd party
# MQTT vendor), you will need to change this line.
DEFAULT_MQTT_WS = \
DEFAULT_MQTT_WS =
"#{ENV["FORCE_SSL"] ? "wss://" : "ws://"}#{ENV.fetch("MQTT_HOST")}:3002/ws"
MQTT_WS = ENV["MQTT_WS"] || DEFAULT_MQTT_WS
EXPIRY = 40.days
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
BETA_OS_URL = ENV["BETA_OTA_URL"] || DEFAULT_BETA_URL
MQTT_WS = ENV["MQTT_WS"] || DEFAULT_MQTT_WS
EXPIRY = 40.days
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
BETA_OS_URL = ENV["BETA_OTA_URL"] || DEFAULT_BETA_URL
# Originally imported from `CalculateVersion` mutation (check source control
# for context) - RC
OS_RELEASE_SERVER = ENV.fetch("OS_UPDATE_SERVER") { DEFAULT_OS }
def self.issue_to(user,
iat: Time.now.to_i,
exp: EXPIRY.from_now.to_i,
iss: $API_URL,
aud: AbstractJwtToken::UNKNOWN_AUD,
fbos_version:) # Gem::Version
unless user.verified?
Rollbar.info("Verification Error", email: user.email)
raise Errors::Forbidden, MUST_VERIFY
end
url = CalculateUpgrade.run!(version: fbos_version)
jti = SecureRandom.uuid
TokenIssuance.create!(device_id: user.device.id, exp: exp, jti: jti)
self.new([{ aud: aud,
sub: user.id,
iat: iat,
jti: jti,
iss: iss,
exp: exp,
mqtt: MQTT,
bot: "device_#{user.device.id}",
vhost: VHOST,
mqtt_ws: MQTT_WS,
os_update_server: url,
beta_os_update_server: BETA_OS_URL }])
self.new([{ aud: aud,
sub: user.id,
iat: iat,
jti: jti,
iss: iss,
exp: exp,
mqtt: MQTT,
bot: "device_#{user.device.id}",
vhost: VHOST,
mqtt_ws: MQTT_WS,
os_update_server: OS_RELEASE_SERVER,
beta_os_update_server: BETA_OS_URL }])
end
def self.as_json(user, aud, fbos_version)
{ token: SessionToken.issue_to(user, iss: $API_URL,
aud: aud,
fbos_version: fbos_version),
user: user }
user: user }
end
end

View File

@ -1,40 +1,40 @@
# Farmbot Device models all data related to an actual FarmBot in the real world.
class Device < ApplicationRecord
DEFAULT_MAX_CONFIGS = 100
DEFAULT_MAX_IMAGES = 100
DEFAULT_MAX_LOGS = 1000
DEFAULT_MAX_IMAGES = 100
DEFAULT_MAX_LOGS = 1000
TIMEZONES = TZInfo::Timezone.all_identifiers
BAD_TZ = "%{value} is not a valid timezone"
THROTTLE_ON = "Device is sending too many logs (%s). " \
"Suspending log storage and display until %s."
THROTTLE_OFF = "Cooldown period has ended. "\
"Resuming log storage."
CACHE_KEY = "devices.%s"
TIMEZONES = TZInfo::Timezone.all_identifiers
BAD_TZ = "%{value} is not a valid timezone"
THROTTLE_ON = "Device is sending too many logs (%s). " \
"Suspending log storage and display until %s."
THROTTLE_OFF = "Cooldown period has ended. " \
"Resuming log storage."
CACHE_KEY = "devices.%s"
has_many :farmware_envs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :farmware_installations, dependent: :destroy
has_many :images, dependent: :destroy
has_many :logs, dependent: :destroy
has_many :peripherals, dependent: :destroy
has_many :pin_bindings, dependent: :destroy
has_many :plant_templates, dependent: :destroy
has_many :points, dependent: :destroy
has_many :regimens, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :sensor_readings, dependent: :destroy
has_many :sensors, dependent: :destroy
has_many :sequences, dependent: :destroy
has_many :token_issuances, dependent: :destroy
has_many :tools, dependent: :destroy
has_many :webcam_feeds, dependent: :destroy
has_many :diagnostic_dumps, dependent: :destroy
has_many :fragments, dependent: :destroy
has_one :fbos_config, dependent: :destroy
has_many :in_use_tools
has_many :in_use_points
has_many :users
has_many :farmware_envs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :farmware_installations, dependent: :destroy
has_many :images, dependent: :destroy
has_many :logs, dependent: :destroy
has_many :peripherals, dependent: :destroy
has_many :pin_bindings, dependent: :destroy
has_many :plant_templates, dependent: :destroy
has_many :points, dependent: :destroy
has_many :regimens, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :sensor_readings, dependent: :destroy
has_many :sensors, dependent: :destroy
has_many :sequences, dependent: :destroy
has_many :token_issuances, dependent: :destroy
has_many :tools, dependent: :destroy
has_many :webcam_feeds, dependent: :destroy
has_many :diagnostic_dumps, dependent: :destroy
has_many :fragments, dependent: :destroy
has_one :fbos_config, dependent: :destroy
has_many :in_use_tools
has_many :in_use_points
has_many :users
validates_presence_of :name
validates :timezone,
@ -73,7 +73,7 @@ class Device < ApplicationRecord
# background jobs. If you are not receiving auto_sync data on your client,
# you probably need to use this method.
def auto_sync_transaction
prev = Device.current
prev = Device.current
Device.current = self
yield
Device.current = prev
@ -114,7 +114,7 @@ class Device < ApplicationRecord
if (violation && throttled_until.nil?)
et = violation.ends_at
reload.update_attributes!(throttled_until: et,
throttled_at: Time.now)
throttled_at: Time.now)
refresh_cache
cooldown = et.in_time_zone(self.timezone || "UTC").strftime("%I:%M%p")
info = [violation.explanation, cooldown]
@ -131,16 +131,17 @@ class Device < ApplicationRecord
cooldown_notice(THROTTLE_OFF, old_time, "info")
end
end
# Send a realtime message to a logged in user.
def tell(message, channels = [], type = "info")
log = Log.new({ device: self,
message: message,
created_at: Time.now,
channels: channels,
major_version: 99,
minor_version: 99,
meta: {},
type: type })
log = Log.new({ device: self,
message: message,
created_at: Time.now,
channels: channels,
major_version: 99,
minor_version: 99,
meta: {},
type: type })
json = LogSerializer.new(log).as_json.to_json
Transport.current.amqp_send(json, self.id, "logs")
@ -148,9 +149,9 @@ class Device < ApplicationRecord
end
def cooldown_notice(message, throttle_time, type, now = Time.current)
hours = ((throttle_time - now) / 1.hour).round
hours = ((throttle_time - now) / 1.hour).round
channels = [(hours > 2) ? "email" : "toast"]
tell(message, channels , type).save
tell(message, channels, type).save
end
def regimina
@ -182,7 +183,7 @@ class Device < ApplicationRecord
# Used by sys admins to debug problems without performing a password reset.
def create_token
# If something manages to call this method, I'd like to be alerted of it.
Rollbar.error("Someone is creating a debug user token", {device: self.id})
Rollbar.error("Someone is creating a debug user token", { device: self.id })
fbos_version = Api::AbstractController::EXPECTED_VER
SessionToken
.as_json(users.first, "SUPER", fbos_version)

View File

@ -8,68 +8,33 @@ module Logs
# Had to change this from "model" to "duck" as a result.
# See Device#refresh_cache(). Rails thinks cached `Device` objects
# are unsaved.
duck :device, methods: [:id, :is_device]
duck :device, methods: [:id, :is_device]
string :message
end
optional do
array :channels,
class: String,
in: CeleryScriptSettingsBag::ALLOWED_CHANNEL_NAMES
# LEGACY SHIM AHEAD!!! ===================================================
#
# We once stored certain fields in a `meta` column.
# The API has evolved since that time, the requirements are pretty solid
# at this point and we need the ability to perform SQL queries. The `meta`
# field is no longer useful nor is it a clean solution.
#
# Legacy FBOS versions expect logs to be in the same shape as before
# and they also produce logs with the expectation that logs have a `meta`
# field.
#
# We will keep the `meta` field around for now, but ideally, API users
# should access `log.FOO` instead of `log.meta.FOO` for future
# compatibility.
#
# TODO: delete the `meta` field once FBOS < v6.4.0 reach EOL.
string :type, in: Log::TYPES
in: CeleryScriptSettingsBag::ALLOWED_CHANNEL_NAMES,
default: []
string :type, in: Log::TYPES, default: "info"
float :x
float :y
float :z
integer :verbosity
integer :verbosity, default: 1
integer :major_version
integer :minor_version
integer :created_at
hash :meta do # This can be transitioned out soon.
string :type, in: Log::TYPES
optional do
float :x
float :y
float :z
integer :verbosity
integer :major_version
integer :minor_version
end
end
# END LEGACY SHIM ========================================================
end
def validate
add_error :log, :private, BAD_WORDS if has_bad_words
add_error :device, :no_id, "ID of device can't be nil" unless device.id
@log = Log.new
@log.device_id = device.id
@log.message = message
@log.channels = channels || []
@log.x = transitional_field(:x)
@log.y = transitional_field(:y)
@log.z = transitional_field(:z)
@log.verbosity = transitional_field(:verbosity, 1)
@log.major_version = transitional_field(:major_version)
@log.minor_version = transitional_field(:minor_version)
@log.type = transitional_field(:type, "info")
@log.created_at = DateTime.strptime(created_at.to_s,'%s') if created_at
# I think this was here for legacy reasons and can be removed.
# Delete this comment if no log issues are noted after 25 MAR 19. - RC
# add_error :device, :no_id, "ID of device can't be nil" unless device.id
@log = Log.new(clean_inputs)
@log.validate!
end
@ -80,6 +45,17 @@ module Logs
private
def clean_inputs
@clean_inputs ||= inputs
.except(:device, :created_at)
.merge(created_at: formatted_timestamp, device_id: device.id)
end
def formatted_timestamp
@formatted_timestamp ||= created_at ?
DateTime.strptime(created_at.to_s, "%s") : nil
end
def maybe_deliver
Log.delay.deliver(device, @log)
end
@ -87,11 +63,5 @@ module Logs
def has_bad_words
!!inputs[:message].upcase.match(BLACKLIST)
end
# Helper for dealing with the gradual removal of the meta field.
def transitional_field(name, default = nil)
m = meta || {} # New logs wont have `meta`.
return inputs[name] || m[name] || m[name.to_s] || default
end
end
end

View File

@ -22,13 +22,6 @@ module Sequences
# first level, though. Because of how EdgeNode and PrimaryNode work,
# superfluous attributes will disappear on save and that's OK.
(inputs[:body] || []).map! { |x| x.slice(*ALLOWED_NODE_KEYS) }
has_scope = inputs.dig(:args, :locals, :body).present?
if has_scope
sequence_id = inputs[:sequence].try(:id)
has_ri = \
sequence_id && RegimenItem.where(sequence_id: sequence_id).present?
add_error :parent, :parent, NO_REGIMENS if has_ri
end
add_error :body, :syntax_error, checker.error.message if !checker.valid?
end

View File

@ -47,23 +47,6 @@ describe Api::SequencesController do
expect(response.status).to eq(200)
end
it 'disallows adding `parent` to sequences used in a regimen' do
sign_in user
sequence = FakeSequence.create(device: user.device)
regimen = Regimens::Create.run!(device: user.device,
name: "X",
color: "red",
regimen_items: [
{ time_offset: 10, sequence_id: sequence.id}
])
try_to_add_parent(sequence)
expect(response.status).to eq(422)
err = \
Sequences::CeleryScriptValidators::NO_REGIMENS
expect(json[:parent]).to include(err)
end
it 'does not let you use other peoples point resources' do
sign_in user
sequence = FakeSequence.create( device: user.device)

View File

@ -1,24 +1,24 @@
require 'spec_helper'
require "spec_helper"
describe SessionToken do
let(:user) { FactoryBot.create(:user) }
FAKE_TOKEN = [
{ "sub" => "admin@admin.com",
"iat" => 1474570171,
"jti" => "c315f378-a318-4d4c-ba06-e4544cbc0621",
"iss" => "//localhost:3000",
"exp" => 1474915771,
"mqtt" => "localhost",
"bot" => "04b57247-763a-4e99-b1a7-3743fe946a1a" },
{ "typ" => "JWT",
"alg" => "RS256" }
]
{ "sub" => "admin@admin.com",
"iat" => 1474570171,
"jti" => "c315f378-a318-4d4c-ba06-e4544cbc0621",
"iss" => "//localhost:3000",
"exp" => 1474915771,
"mqtt" => "localhost",
"bot" => "04b57247-763a-4e99-b1a7-3743fe946a1a" },
{ "typ" => "JWT",
"alg" => "RS256" },
]
it 'initializes' do
it "initializes" do
token = SessionToken.new(FAKE_TOKEN)
expect(token.unencoded).to be_kind_of(Hash)
actual = token.unencoded
actual = token.unencoded
expected = FAKE_TOKEN[0]
expect(actual["sub"]).to eq(expected["sub"])
expect(actual["iat"]).to eq(expected["iat"])
@ -29,43 +29,23 @@ describe SessionToken do
expect(actual["bot"]).to eq(expected["bot"])
end
it 'issues a token to a user' do
it "issues a token to a user" do
token = SessionToken.issue_to(user, iat: 000,
exp: 456,
iss: "//lycos.com:9867",
fbos_version: Gem::Version.new("9.9.9"))
exp: 456,
iss: "//lycos.com:9867",
fbos_version: Gem::Version.new("9.9.9"))
expect(token.unencoded[:beta_os_update_server]).to be_kind_of(String)
end
it 'conditionally sets `os_update_server`' do
test_case = -> (ver) do
SessionToken
.issue_to(user, fbos_version: Gem::Version.new(ver))
.unencoded[:os_update_server]
end
expect(test_case["0.0.0"]).to eq(CalculateUpgrade::OLD_OS_URL)
expect(test_case["5.0.5"]).to eq(CalculateUpgrade::OLD_OS_URL)
expect(test_case["5.0.6"]).to eq(CalculateUpgrade::OLD_OS_URL)
expect(test_case["5.0.8"]).to eq(CalculateUpgrade::MID_OS_URL)
expect(test_case["5.0.9"]).to eq(CalculateUpgrade::MID_OS_URL)
expect(test_case["6.0.1"]).to eq(CalculateUpgrade::OS_RELEASE)
expect(test_case["999.999.999"])
.to eq(CalculateUpgrade::OS_RELEASE)
expect(test_case["0.0.0"]).to eq(CalculateUpgrade::OLD_OS_URL)
expect(test_case[CalculateUpgrade::NOT_FBOS]).to eq(CalculateUpgrade::OS_RELEASE)
end
it "doesn't honor expired tokens" do
user.update_attributes!(confirmed_at: Time.now)
token = SessionToken.issue_to(user, iat: 000,
exp: 1,
iss: "//lycos.com:9867",
fbos_version: Gem::Version.new("9.9.9"))
token = SessionToken.issue_to(user, iat: 000,
exp: 1,
iss: "//lycos.com:9867",
fbos_version: Gem::Version.new("9.9.9"))
result = Auth::FromJWT.run(jwt: token.encoded)
expect(result.success?).to be(false)
expect(result.errors.values.first.message)
.to eq(Auth::ReloadToken::BAD_SUB)
expect(result.errors.values.first.message).to eq(Auth::ReloadToken::BAD_SUB)
end
unless ENV["NO_EMAILS"]

View File

@ -1,58 +1,43 @@
require 'spec_helper'
require "spec_helper"
describe Auth::FromJWT do
let(:user) { FactoryBot.create(:user) }
let(:user) { FactoryBot.create(:user) }
def fake_credentials(email, password)
# Input -> JSONify -> encrypt -> Base64ify
secret = { email: email, password: password }.to_json
ct = KeyGen.current.public_encrypt(secret)
ct = KeyGen.current.public_encrypt(secret)
return Base64.encode64(ct)
end
it 'rejects bad credentials' do
it "rejects bad credentials" do
results = Auth::CreateTokenFromCredentials
.run(credentials: "FOO", fbos_version: Gem::Version.new("999.9.9"))
expect(results.success?).to eq(false)
expect(results.errors.message_list)
.to include(Auth::CreateTokenFromCredentials::BAD_KEY)
expect(results.errors.message_list).to include(Auth::CreateTokenFromCredentials::BAD_KEY)
end
it 'accepts good credentials' do
pw = "password123"
user = FactoryBot.create(:user, password: pw)
email = user.email
creds = fake_credentials(email, pw)
it "accepts good credentials" do
pw = "password123"
user = FactoryBot.create(:user, password: pw)
email = user.email
creds = fake_credentials(email, pw)
results = Auth::CreateTokenFromCredentials
.run!(credentials: creds, fbos_version: Gem::Version.new("999.9.9"))
expect(results[:token]).to be_kind_of(SessionToken)
expect(results[:user]).to eq(user)
expect(results[:token].unencoded[:os_update_server]).to eq(CalculateUpgrade::OS_RELEASE)
expect(results[:token].unencoded[:os_update_server]).to eq(SessionToken::OS_RELEASE_SERVER)
end
it 'Rejects bad email / password' do
it "Rejects bad email / password" do
input = {
email: "x@y.z",
password: "password123",
fbos_version: Gem::Version.new("10.9.8")
email: "x@y.z",
password: "password123",
fbos_version: Gem::Version.new("10.9.8"),
}
x = Auth::CreateToken.run(input)
expect(x.success?).to be false
expect(x.errors["auth"]).to be
expect(x.errors["auth"].message_list).to include("Bad email or password.")
end
it 'sometimes renders the legacy URL' do
pw = "password123"
user = FactoryBot.create(:user, password: pw)
email = user.email
creds = fake_credentials(email, pw)
results = Auth::CreateTokenFromCredentials
.run!(credentials: creds, fbos_version: Gem::Version.new("5.0.5"))
expect(results[:token]).to be_kind_of(SessionToken)
expect(results[:user]).to eq(user)
expect(results[:token].unencoded[:os_update_server])
.to eq(CalculateUpgrade::OLD_OS_URL)
end
end