mirror of
https://github.com/we-promise/sure.git
synced 2026-04-23 14:04:06 +00:00
* fix(security): sanitize exception messages in API responses (FIX-11)
Replace raw e.message/error.message interpolations in response bodies
with generic error strings, and log class+message server-side. Prevents
leaking internal exception details (stack traces, SQL fragments, record
data) to API clients.
Covers:
- API v1 accounts, categories (index/show), holdings, sync, trades,
transactions (index/show/create/update/destroy), valuations
(show/create/update): replace "Error: #{e.message}" with
"An unexpected error occurred".
- API v1 auth: device-registration rescue paths now log
"[Auth] Device registration failed: ..." and respond with
"Failed to register device".
- WebhooksController#plaid and #plaid_eu: log full error and respond
with "Invalid webhook".
- Settings::ProvidersController: generic user-facing flash alert,
detailed log line with error class + message.
Updates providers_controller_test assertion to match sanitized flash.
* fix(security): address CodeRabbit review
Major — partial-commit on device registration failure:
- Strengthened valid_device_info? to also run MobileDevice's model
validations up-front (device_type inclusion, attribute presence), not
just a flat "are the keys present?" check. A client that sends a bad
device_type ("windows", etc.) is now rejected at the API boundary
BEFORE signup commits any user/family/invite state.
- Wrapped the signup path (user.save + InviteCode.claim + MobileDevice
upsert + token issuance) in ActiveRecord::Base.transaction. A
post-save RecordInvalid from device registration (e.g., racing
uniqueness on device_id) now rolls back the user/invite/family so
clients don't see a partial-account state.
- Rescue branch logs the exception class + message ("#{e.class} - #{e.message}")
for better postmortem debugging, matching the providers controller
pattern.
Nit:
- Tightened providers_controller_test log expectation regex to assert on
both the exception class name AND the message ("StandardError - Database
error"), so a regression that drops either still fails the test.
Tests:
- New: "should reject signup with invalid device_type before committing
any state" — POST /api/v1/auth/signup with device_type="windows"
returns 400 AND asserts no User, MobileDevice, or Doorkeeper::AccessToken
row was created.
Note on SSO path (sso_exchange → issue_mobile_tokens, lines 173/225): the
device_info in those flows comes from Rails.cache (populated by an earlier
request that already passed valid_device_info?), so the pre-validation
covers it indirectly. Wrapping the full SSO account creation (user +
invitation + OidcIdentity + issue_mobile_tokens) in one transaction would
be a meaningful architectural cleanup but is out of scope for this
error-hygiene PR — filed it as a mental note for a follow-up.
841 lines
26 KiB
Ruby
841 lines
26 KiB
Ruby
require "test_helper"
|
|
|
|
class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
# Clean up any existing invite codes
|
|
InviteCode.destroy_all
|
|
@device_info = {
|
|
device_id: "test-device-123",
|
|
device_name: "Test iPhone",
|
|
device_type: "ios",
|
|
os_version: "17.0",
|
|
app_version: "1.0.0"
|
|
}
|
|
|
|
# Ensure the shared OAuth application exists
|
|
@shared_app = Doorkeeper::Application.find_or_create_by!(name: "Sure Mobile") do |app|
|
|
app.redirect_uri = "sureapp://oauth/callback"
|
|
app.scopes = "read read_write"
|
|
app.confidential = false
|
|
end
|
|
@shared_app.update!(scopes: "read read_write")
|
|
|
|
# Clear the memoized class variable so it picks up the test record
|
|
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
|
|
|
|
# Use a real cache store for SSO linking tests (test env uses :null_store by default)
|
|
@original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
end
|
|
|
|
teardown do
|
|
Rails.cache = @original_cache if @original_cache
|
|
end
|
|
|
|
test "should signup new user and return OAuth tokens" do
|
|
assert_difference("User.count", 1) do
|
|
assert_difference("MobileDevice.count", 1) do
|
|
assert_no_difference("Doorkeeper::Application.count") do
|
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
assert_response :created
|
|
response_data = JSON.parse(response.body)
|
|
|
|
assert response_data["user"]["id"].present?
|
|
assert_equal "newuser@example.com", response_data["user"]["email"]
|
|
assert_equal "New", response_data["user"]["first_name"]
|
|
assert_equal "User", response_data["user"]["last_name"]
|
|
new_user = User.find(response_data["user"]["id"])
|
|
assert_equal new_user.ui_layout, response_data["user"]["ui_layout"]
|
|
assert_equal new_user.ai_enabled?, response_data["user"]["ai_enabled"]
|
|
|
|
# OAuth token assertions
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
assert_equal "Bearer", response_data["token_type"]
|
|
assert_equal 2592000, response_data["expires_in"] # 30 days
|
|
assert response_data["created_at"].present?
|
|
|
|
# Verify the device was created
|
|
created_user = User.find(response_data["user"]["id"])
|
|
device = created_user.mobile_devices.first
|
|
assert_equal @device_info[:device_id], device.device_id
|
|
assert_equal @device_info[:device_name], device.device_name
|
|
assert_equal @device_info[:device_type], device.device_type
|
|
end
|
|
|
|
test "should not signup without device info" do
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
}
|
|
}
|
|
end
|
|
|
|
assert_response :bad_request
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Device information is required", response_data["error"]
|
|
end
|
|
|
|
test "should reject signup with invalid device_type before committing any state" do
|
|
# Pre-validation catches bad device_type and returns 400 without creating
|
|
# user/family/device/token. Guards against a partial-commit state where the
|
|
# account exists but the mobile session handoff fails.
|
|
assert_no_difference([ "User.count", "MobileDevice.count", "Doorkeeper::AccessToken.count" ]) do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info.merge(device_type: "windows") # not in allowlist
|
|
}
|
|
end
|
|
|
|
assert_response :bad_request
|
|
end
|
|
|
|
test "should not signup with invalid password" do
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "weak",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["errors"].include?("Password must be at least 8 characters")
|
|
end
|
|
|
|
test "should not signup with duplicate email" do
|
|
existing_user = users(:family_admin)
|
|
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: existing_user.email,
|
|
password: "SecurePass123!",
|
|
first_name: "Duplicate",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
end
|
|
|
|
test "should create user with admin role and family" do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info
|
|
}
|
|
|
|
assert_response :created
|
|
response_data = JSON.parse(response.body)
|
|
|
|
new_user = User.find(response_data["user"]["id"])
|
|
assert_equal "admin", new_user.role
|
|
assert new_user.family.present?
|
|
end
|
|
|
|
test "should require invite code when enabled" do
|
|
# Mock invite code requirement
|
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
|
|
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :forbidden
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invite code is required", response_data["error"]
|
|
end
|
|
|
|
test "should signup with valid invite code when required" do
|
|
# Create a valid invite code
|
|
invite_code = InviteCode.create!
|
|
|
|
# Mock invite code requirement
|
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)
|
|
|
|
assert_difference("User.count", 1) do
|
|
assert_difference("InviteCode.count", -1) do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info,
|
|
invite_code: invite_code.token
|
|
}
|
|
end
|
|
end
|
|
|
|
assert_response :created
|
|
end
|
|
|
|
test "should reject invalid invite code" do
|
|
# Mock invite code requirement
|
|
Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(false)
|
|
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/signup", params: {
|
|
user: {
|
|
email: "newuser@example.com",
|
|
password: "SecurePass123!",
|
|
first_name: "New",
|
|
last_name: "User"
|
|
},
|
|
device: @device_info,
|
|
invite_code: "invalid_code"
|
|
}
|
|
end
|
|
|
|
assert_response :forbidden
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invalid invite code", response_data["error"]
|
|
end
|
|
|
|
test "should login existing user and return OAuth tokens" do
|
|
user = users(:family_admin)
|
|
password = user_password_test
|
|
|
|
# Ensure user has no mobile devices
|
|
user.mobile_devices.destroy_all
|
|
|
|
assert_difference("MobileDevice.count", 1) do
|
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: password,
|
|
device: @device_info
|
|
}
|
|
end
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
|
|
assert_equal user.id.to_s, response_data["user"]["id"]
|
|
assert_equal user.email, response_data["user"]["email"]
|
|
assert_equal user.ui_layout, response_data["user"]["ui_layout"]
|
|
assert_equal user.ai_enabled?, response_data["user"]["ai_enabled"]
|
|
|
|
# OAuth token assertions
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
assert_equal "Bearer", response_data["token_type"]
|
|
assert_equal 2592000, response_data["expires_in"] # 30 days
|
|
|
|
# Verify the device
|
|
device = user.mobile_devices.where(device_id: @device_info[:device_id]).first
|
|
assert device.present?
|
|
assert device.active?
|
|
end
|
|
|
|
test "should require MFA when enabled" do
|
|
user = users(:family_admin)
|
|
password = user_password_test
|
|
|
|
# Enable MFA for user
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: password,
|
|
device: @device_info
|
|
}
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Two-factor authentication required", response_data["error"]
|
|
assert response_data["mfa_required"]
|
|
end
|
|
|
|
test "should login with valid MFA code" do
|
|
user = users(:family_admin)
|
|
password = user_password_test
|
|
|
|
# Enable MFA for user
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
totp = ROTP::TOTP.new(user.otp_secret)
|
|
|
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: password,
|
|
otp_code: totp.now,
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["access_token"].present?
|
|
end
|
|
|
|
test "should revoke existing tokens for same device on login" do
|
|
user = users(:family_admin)
|
|
password = user_password_test
|
|
|
|
# Create an existing device and token
|
|
device = user.mobile_devices.create!(@device_info)
|
|
existing_token = Doorkeeper::AccessToken.create!(
|
|
application: @shared_app,
|
|
resource_owner_id: user.id,
|
|
mobile_device_id: device.id,
|
|
expires_in: 30.days.to_i,
|
|
scopes: "read_write"
|
|
)
|
|
|
|
assert existing_token.accessible?
|
|
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: password,
|
|
device: @device_info
|
|
}
|
|
|
|
assert_response :success
|
|
|
|
# Check that old token was revoked
|
|
existing_token.reload
|
|
assert existing_token.revoked?
|
|
end
|
|
|
|
test "should not login with invalid password" do
|
|
user = users(:family_admin)
|
|
|
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: "wrong_password",
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invalid email or password", response_data["error"]
|
|
end
|
|
|
|
test "should not login with non-existent email" do
|
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
|
post "/api/v1/auth/login", params: {
|
|
email: "nonexistent@example.com",
|
|
password: user_password_test,
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invalid email or password", response_data["error"]
|
|
end
|
|
|
|
test "should login even when OAuth application is missing" do
|
|
user = users(:family_admin)
|
|
password = user_password_test
|
|
|
|
# Simulate a fresh instance where seeds were never run
|
|
Doorkeeper::Application.where(name: "Sure Mobile").destroy_all
|
|
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
|
|
|
|
assert_difference("Doorkeeper::Application.count", 1) do
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: password,
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
end
|
|
|
|
test "should not login without device info" do
|
|
user = users(:family_admin)
|
|
|
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
|
post "/api/v1/auth/login", params: {
|
|
email: user.email,
|
|
password: user_password_test
|
|
}
|
|
end
|
|
|
|
assert_response :bad_request
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Device information is required", response_data["error"]
|
|
end
|
|
|
|
test "should refresh access token with valid refresh token" do
|
|
user = users(:family_admin)
|
|
device = user.mobile_devices.create!(@device_info)
|
|
|
|
# Create initial token
|
|
initial_token = Doorkeeper::AccessToken.create!(
|
|
application: @shared_app,
|
|
resource_owner_id: user.id,
|
|
mobile_device_id: device.id,
|
|
expires_in: 30.days.to_i,
|
|
scopes: "read_write",
|
|
use_refresh_token: true
|
|
)
|
|
|
|
# Wait to ensure different timestamps
|
|
sleep 0.1
|
|
|
|
assert_difference("Doorkeeper::AccessToken.count", 1) do
|
|
post "/api/v1/auth/refresh", params: {
|
|
refresh_token: initial_token.refresh_token,
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
|
|
# New token assertions
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
assert_not_equal initial_token.token, response_data["access_token"]
|
|
assert_equal 2592000, response_data["expires_in"]
|
|
|
|
# Old token should be revoked
|
|
initial_token.reload
|
|
assert initial_token.revoked?
|
|
end
|
|
|
|
test "should not refresh with invalid refresh token" do
|
|
assert_no_difference("Doorkeeper::AccessToken.count") do
|
|
post "/api/v1/auth/refresh", params: {
|
|
refresh_token: "invalid_token",
|
|
device: @device_info
|
|
}
|
|
end
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invalid refresh token", response_data["error"]
|
|
end
|
|
|
|
test "should not refresh without refresh token" do
|
|
post "/api/v1/auth/refresh", params: {
|
|
device: @device_info
|
|
}
|
|
|
|
assert_response :bad_request
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Refresh token is required", response_data["error"]
|
|
end
|
|
|
|
test "should enable ai for authenticated user" do
|
|
user = users(:family_admin)
|
|
user.update!(ai_enabled: false)
|
|
device = user.mobile_devices.create!(@device_info)
|
|
token = Doorkeeper::AccessToken.create!(application: @shared_app, resource_owner_id: user.id, mobile_device_id: device.id, scopes: "read_write")
|
|
|
|
patch "/api/v1/auth/enable_ai", headers: {
|
|
"Authorization" => "Bearer #{token.token}",
|
|
"Content-Type" => "application/json"
|
|
}
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal true, response_data.dig("user", "ai_enabled")
|
|
assert_equal user.ui_layout, response_data.dig("user", "ui_layout")
|
|
assert_equal true, user.reload.ai_enabled
|
|
end
|
|
|
|
test "should require read_write scope to enable ai" do
|
|
user = users(:family_admin)
|
|
user.update!(ai_enabled: false)
|
|
device = user.mobile_devices.create!(@device_info)
|
|
token = Doorkeeper::AccessToken.create!(application: @shared_app, resource_owner_id: user.id, mobile_device_id: device.id, scopes: "read")
|
|
|
|
patch "/api/v1/auth/enable_ai", headers: {
|
|
"Authorization" => "Bearer #{token.token}",
|
|
"Content-Type" => "application/json"
|
|
}
|
|
|
|
assert_response :forbidden
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "insufficient_scope", response_data["error"]
|
|
assert_equal "This action requires the 'write' scope", response_data["message"]
|
|
assert_not user.reload.ai_enabled
|
|
end
|
|
|
|
test "should require authentication when enabling ai" do
|
|
patch "/api/v1/auth/enable_ai", headers: { "Content-Type" => "application/json" }
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
# SSO Link tests
|
|
test "should link existing account via SSO and return tokens" do
|
|
user = users(:family_admin)
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-123",
|
|
email: "google@example.com",
|
|
first_name: "Google",
|
|
last_name: "User",
|
|
name: "Google User",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_difference("OidcIdentity.count", 1) do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: linking_code,
|
|
email: user.email,
|
|
password: user_password_test
|
|
}
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
assert_equal user.id.to_s, response_data["user"]["id"]
|
|
|
|
# Linking code should be consumed
|
|
assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}")
|
|
end
|
|
|
|
test "should reject SSO link with invalid password" do
|
|
user = users(:family_admin)
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-123",
|
|
email: "google@example.com",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_no_difference("OidcIdentity.count") do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: linking_code,
|
|
email: user.email,
|
|
password: "wrong_password"
|
|
}
|
|
end
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Invalid email or password", response_data["error"]
|
|
|
|
# Linking code should NOT be consumed on failed password
|
|
assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a failed attempt"
|
|
end
|
|
|
|
test "should reject SSO link when user has MFA enabled" do
|
|
user = users(:family_admin)
|
|
user.update!(otp_required: true, otp_secret: ROTP::Base32.random(32))
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-mfa",
|
|
email: "mfa@example.com",
|
|
first_name: "MFA",
|
|
last_name: "User",
|
|
name: "MFA User",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_no_difference("OidcIdentity.count") do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: linking_code,
|
|
email: user.email,
|
|
password: user_password_test
|
|
}
|
|
end
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal true, response_data["mfa_required"]
|
|
assert_match(/MFA/, response_data["error"])
|
|
|
|
# Linking code should NOT be consumed on MFA rejection
|
|
assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive MFA rejection"
|
|
end
|
|
|
|
test "should reject SSO link with expired linking code" do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: "expired-code",
|
|
email: "test@example.com",
|
|
password: "password"
|
|
}
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Linking code is invalid or expired", response_data["error"]
|
|
end
|
|
|
|
test "should reject SSO link without linking code" do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
email: "test@example.com",
|
|
password: "password"
|
|
}
|
|
|
|
assert_response :bad_request
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Linking code is required", response_data["error"]
|
|
end
|
|
|
|
test "linking_code is single-use under race" do
|
|
user = users(:family_admin)
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-race-test",
|
|
email: "race@example.com",
|
|
first_name: "Race",
|
|
last_name: "Test",
|
|
name: "Race Test",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
# First request succeeds
|
|
assert_difference("OidcIdentity.count", 1) do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: linking_code,
|
|
email: user.email,
|
|
password: user_password_test
|
|
}
|
|
end
|
|
assert_response :success
|
|
|
|
# Second request with the same code is rejected
|
|
assert_no_difference("OidcIdentity.count") do
|
|
post "/api/v1/auth/sso_link", params: {
|
|
linking_code: linking_code,
|
|
email: user.email,
|
|
password: user_password_test
|
|
}
|
|
end
|
|
assert_response :unauthorized
|
|
assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"]
|
|
assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}")
|
|
end
|
|
|
|
# SSO Create Account tests
|
|
test "should create new account via SSO and return tokens" do
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-456",
|
|
email: "newgoogleuser@example.com",
|
|
first_name: "New",
|
|
last_name: "GoogleUser",
|
|
name: "New GoogleUser",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_difference([ "User.count", "OidcIdentity.count" ], 1) do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: linking_code,
|
|
first_name: "New",
|
|
last_name: "GoogleUser"
|
|
}
|
|
end
|
|
|
|
assert_response :success
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["access_token"].present?
|
|
assert response_data["refresh_token"].present?
|
|
assert_equal "newgoogleuser@example.com", response_data["user"]["email"]
|
|
assert_equal "New", response_data["user"]["first_name"]
|
|
assert_equal "GoogleUser", response_data["user"]["last_name"]
|
|
|
|
# Linking code should be consumed
|
|
assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}")
|
|
end
|
|
|
|
test "should reject SSO create account when not allowed" do
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-789",
|
|
email: "blocked@example.com",
|
|
first_name: "Blocked",
|
|
last_name: "User",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: false
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_no_difference("User.count") do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: linking_code,
|
|
first_name: "Blocked",
|
|
last_name: "User"
|
|
}
|
|
end
|
|
|
|
assert_response :forbidden
|
|
response_data = JSON.parse(response.body)
|
|
assert_match(/disabled/, response_data["error"])
|
|
|
|
# Linking code should NOT be consumed on rejection
|
|
assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a rejected create account attempt"
|
|
end
|
|
|
|
test "should reject SSO create account with expired linking code" do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: "expired-code",
|
|
first_name: "Test",
|
|
last_name: "User"
|
|
}
|
|
|
|
assert_response :unauthorized
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Linking code is invalid or expired", response_data["error"]
|
|
end
|
|
|
|
test "should reject SSO create account without linking code" do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
first_name: "Test",
|
|
last_name: "User"
|
|
}
|
|
|
|
assert_response :bad_request
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "Linking code is required", response_data["error"]
|
|
end
|
|
|
|
test "should return 422 when SSO create account fails user validation" do
|
|
existing_user = users(:family_admin)
|
|
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-dup-email",
|
|
email: existing_user.email,
|
|
first_name: "Duplicate",
|
|
last_name: "Email",
|
|
name: "Duplicate Email",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
assert_no_difference([ "User.count", "OidcIdentity.count" ]) do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: linking_code,
|
|
first_name: "Duplicate",
|
|
last_name: "Email"
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
response_data = JSON.parse(response.body)
|
|
assert response_data["errors"].any? { |e| e.match?(/email/i) }, "Expected email validation error in: #{response_data["errors"]}"
|
|
end
|
|
|
|
test "sso_create_account linking_code single-use under race" do
|
|
linking_code = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write("mobile_sso_link:#{linking_code}", {
|
|
provider: "google_oauth2",
|
|
uid: "google-uid-race-create",
|
|
email: "raceuser@example.com",
|
|
first_name: "Race",
|
|
last_name: "CreateUser",
|
|
name: "Race CreateUser",
|
|
device_info: @device_info.stringify_keys,
|
|
allow_account_creation: true
|
|
}, expires_in: 10.minutes)
|
|
|
|
# First request succeeds
|
|
assert_difference([ "User.count", "OidcIdentity.count" ], 1) do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: linking_code,
|
|
first_name: "Race",
|
|
last_name: "CreateUser"
|
|
}
|
|
end
|
|
assert_response :success
|
|
|
|
# Second request with the same code is rejected
|
|
assert_no_difference([ "User.count", "OidcIdentity.count" ]) do
|
|
post "/api/v1/auth/sso_create_account", params: {
|
|
linking_code: linking_code,
|
|
first_name: "Race",
|
|
last_name: "CreateUser"
|
|
}
|
|
end
|
|
assert_response :unauthorized
|
|
assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"]
|
|
assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}")
|
|
end
|
|
|
|
test "should return forbidden when ai is not available" do
|
|
user = users(:family_admin)
|
|
user.update!(ai_enabled: false)
|
|
device = user.mobile_devices.create!(@device_info)
|
|
token = Doorkeeper::AccessToken.create!(application: @shared_app, resource_owner_id: user.id, mobile_device_id: device.id, scopes: "read_write")
|
|
User.any_instance.stubs(:ai_available?).returns(false)
|
|
|
|
patch "/api/v1/auth/enable_ai", headers: {
|
|
"Authorization" => "Bearer #{token.token}",
|
|
"Content-Type" => "application/json"
|
|
}
|
|
|
|
assert_response :forbidden
|
|
response_data = JSON.parse(response.body)
|
|
assert_equal "AI is not available for your account", response_data["error"]
|
|
assert_not user.reload.ai_enabled
|
|
end
|
|
end
|