mirror of
https://github.com/we-promise/sure.git
synced 2026-04-22 21:44:11 +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.
219 lines
6.0 KiB
Ruby
219 lines
6.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Api::V1::ValuationsController < Api::V1::BaseController
|
|
before_action :ensure_read_scope, only: [ :show ]
|
|
before_action :ensure_write_scope, only: [ :create, :update ]
|
|
before_action :set_valuation, only: [ :show, :update ]
|
|
|
|
def show
|
|
render :show
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#show error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
def create
|
|
unless valuation_account_id.present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Account ID is required",
|
|
errors: [ "Account ID is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
unless valuation_params[:amount].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Amount is required",
|
|
errors: [ "Amount is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
unless valuation_params[:date].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Date is required",
|
|
errors: [ "Date is required" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
account = current_resource_owner.family.accounts.find(valuation_account_id)
|
|
|
|
create_success = false
|
|
error_payload = nil
|
|
|
|
ActiveRecord::Base.transaction do
|
|
result = account.create_reconciliation(
|
|
balance: valuation_params[:amount],
|
|
date: valuation_params[:date]
|
|
)
|
|
|
|
unless result.success?
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be created",
|
|
errors: [ result.error_message ]
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
@entry = account.entries.valuations.find_by!(date: valuation_params[:date])
|
|
@valuation = @entry.entryable
|
|
|
|
if valuation_params.key?(:notes)
|
|
unless @entry.update(notes: valuation_params[:notes])
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be created",
|
|
errors: @entry.errors.full_messages
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
create_success = true
|
|
end
|
|
|
|
unless create_success
|
|
render json: error_payload, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
render :show, status: :created
|
|
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {
|
|
error: "not_found",
|
|
message: "Account or valuation entry not found"
|
|
}, status: :not_found
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#create error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
def update
|
|
if valuation_params[:date].present? || valuation_params[:amount].present?
|
|
unless valuation_params[:date].present? && valuation_params[:amount].present?
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Both amount and date are required when updating reconciliation",
|
|
errors: [ "Amount and date must both be provided" ]
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
update_success = false
|
|
error_payload = nil
|
|
updated_entry = nil
|
|
|
|
ActiveRecord::Base.transaction do
|
|
result = @entry.account.update_reconciliation(
|
|
@entry,
|
|
balance: valuation_params[:amount],
|
|
date: valuation_params[:date]
|
|
)
|
|
|
|
unless result.success?
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: [ result.error_message ]
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
updated_entry = @entry.account.entries.valuations.find_by!(date: valuation_params[:date])
|
|
|
|
if valuation_params.key?(:notes)
|
|
unless updated_entry.update(notes: valuation_params[:notes])
|
|
error_payload = {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: updated_entry.errors.full_messages
|
|
}
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
update_success = true
|
|
end
|
|
|
|
unless update_success
|
|
render json: error_payload, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
@entry = updated_entry
|
|
@valuation = @entry.entryable
|
|
render :show
|
|
else
|
|
if valuation_params.key?(:notes)
|
|
unless @entry.update(notes: valuation_params[:notes])
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: "Valuation could not be updated",
|
|
errors: @entry.errors.full_messages
|
|
}, status: :unprocessable_entity
|
|
return
|
|
end
|
|
end
|
|
@entry.reload
|
|
@valuation = @entry.entryable
|
|
render :show
|
|
end
|
|
|
|
rescue => e
|
|
Rails.logger.error "ValuationsController#update error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
|
|
private
|
|
|
|
def set_valuation
|
|
@entry = current_resource_owner.family
|
|
.entries
|
|
.where(entryable_type: "Valuation")
|
|
.find(params[:id])
|
|
@valuation = @entry.entryable
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {
|
|
error: "not_found",
|
|
message: "Valuation not found"
|
|
}, status: :not_found
|
|
end
|
|
|
|
def ensure_read_scope
|
|
authorize_scope!(:read)
|
|
end
|
|
|
|
def ensure_write_scope
|
|
authorize_scope!(:write)
|
|
end
|
|
|
|
def valuation_account_id
|
|
params.dig(:valuation, :account_id)
|
|
end
|
|
|
|
def valuation_params
|
|
params.require(:valuation).permit(:amount, :date, :notes)
|
|
end
|
|
end
|