diff --git a/.gitignore b/.gitignore index 26bd403d3..684ac4cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,4 @@ CLAUDE.md /app/assets/builds/* !/app/assets/builds/.keep .cursorindexingignore -.specstory \ No newline at end of file +.specstory diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 000000000..f10f906c1 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,130 @@ +# syntax = docker/dockerfile:1 + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile +ARG RUBY_VERSION=3.4.1 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-bullseye AS base + +# Rails app lives here +WORKDIR /opt/webapps/app + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LANG=et_EE.UTF-8 \ + RAILS_SERVE_STATIC_FILES="true" \ + RAILS_LOG_TO_STDOUT="true" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install packages needed to build gems and node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential \ + imagemagick \ + curl \ + wget \ + gnupg2 \ + git \ + apt-utils \ + libpq-dev \ + libvips \ + node-gyp \ + pkg-config \ + python-is-python3 \ + libxslt1-dev \ + libxml2-dev \ + wkhtmltopdf \ + locales \ + postgresql-client + +# Configure locales +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + sed -i -e 's/# et_EE.UTF-8 UTF-8/et_EE.UTF-8 UTF-8/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=et_EE.UTF-8 + +# Install JavaScript dependencies +ARG NODE_VERSION=18.16.0 +ARG YARN_VERSION=1.22.19 +ENV PATH=/usr/local/node/bin:$PATH +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + npm install -g yarn@$YARN_VERSION && \ + npm install -g n && \ + n stable && \ + rm -rf /tmp/node-build-master + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Install node modules +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --check-files + +# Copy application code +COPY . . + +# Create necessary directories for K8s +RUN mkdir -p /opt/webapps/app/tmp/pids /opt/webapps/app/tmp/k8s /opt/webapps/app/log + +# Make entrypoint script executable +RUN chmod +x /opt/webapps/app/bin/docker-entrypoint + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompile assets with a real random secret key +RUN SECRET_KEY_BASE=$(openssl rand -hex 64) bundle exec rails assets:precompile + +# Final stage for app image +FROM base + +# Install packages needed for deployment (minimal set for production) +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + curl \ + libvips \ + postgresql-client \ + wkhtmltopdf \ + imagemagick \ + locales \ + libxslt1-dev \ + libxml2-dev && \ + sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ + sed -i -e 's/# et_EE.UTF-8 UTF-8/et_EE.UTF-8 UTF-8/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=et_EE.UTF-8 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /opt/webapps/app /opt/webapps/app + +# K8s specific configurations +RUN mkdir -p /opt/webapps/app/tmp/pids /opt/webapps/app/tmp/k8s /opt/webapps/app/storage + +# Ensure entrypoint script is executable in final image +RUN chmod +x /opt/webapps/app/bin/docker-entrypoint + +# Run and own only the runtime files as a non-root user for security +RUN useradd rails --create-home --shell /bin/bash && \ + chown -R rails:rails /opt/webapps/app/log /opt/webapps/app/tmp /opt/webapps/app/storage +USER rails:rails + +# Create a health check endpoint file for Kubernetes probes +RUN touch /opt/webapps/app/tmp/k8s/ready + +# Entrypoint prepares the database. +ENTRYPOINT ["/opt/webapps/app/bin/docker-entrypoint"] + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD ["./bin/rails", "server"] \ No newline at end of file diff --git a/Gemfile b/Gemfile index 09869fe1c..536af2273 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,7 @@ group :development, :test do gem 'bundler-audit' gem 'byebug', platforms: %i[mri mingw x64_mingw] gem 'pry' + gem 'bindex' end group :development do @@ -95,3 +96,6 @@ group :test do gem 'spy' gem 'webmock' end + +gem 'devise-jwt' +gem 'jsonapi-serializer' diff --git a/Gemfile.lock b/Gemfile.lock index 447db40fa..7640e82f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -89,8 +89,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) airbrake (13.0.5) airbrake-ruby (~> 6.0) @@ -101,7 +101,7 @@ GEM attr_required (1.0.2) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.1) + benchmark (0.5.0) bigdecimal (3.3.1) bindata (2.5.1) bindex (0.8.1) @@ -129,7 +129,7 @@ GEM logger (~> 1.5) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.4) + connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml @@ -155,9 +155,21 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-jwt (0.11.0) + devise (~> 4.0) + warden-jwt_auth (~> 0.8) docile (1.4.1) domain_name (0.6.20240107) drb (2.2.3) + dry-auto_inject (1.0.1) + dry-core (~> 1.0) + zeitwerk (~> 2.6) + dry-configurable (1.1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-core (1.0.1) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) email_validator (2.2.4) activemodel erb (5.0.2) @@ -181,6 +193,8 @@ GEM ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86-linux-gnu) + ffi (1.17.2-x86-linux-musl) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) @@ -205,6 +219,12 @@ GEM google-protobuf (4.33.0-arm64-darwin) bigdecimal rake (>= 13) + google-protobuf (4.33.0-x86-linux-gnu) + bigdecimal + rake (>= 13) + google-protobuf (4.33.0-x86-linux-musl) + bigdecimal + rake (>= 13) google-protobuf (4.33.0-x86_64-darwin) bigdecimal rake (>= 13) @@ -256,6 +276,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jsonapi-serializer (2.2.0) + activesupport (>= 4.2) jwt (2.10.2) base64 language_server-protocol (3.17.0.5) @@ -299,7 +321,7 @@ GEM rake mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.0) + minitest (5.26.2) money (6.19.0) i18n (>= 0.6.4, <= 2) msgpack (1.8.0) @@ -371,6 +393,7 @@ GEM pg (1.6.2) pg (1.6.2-aarch64-linux) pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-aarch64-mingw-ucrt) pg (1.6.2-arm64-darwin) pg (1.6.2-x86_64-darwin) pg (1.6.2-x86_64-linux) @@ -392,7 +415,7 @@ GEM psych (5.2.6) date stringio - public_suffix (6.0.2) + public_suffix (7.0.0) puma (7.1.0) nio4r (~> 2.0) racc (1.8.1) @@ -515,18 +538,32 @@ GEM sass-embedded (1.93.2) google-protobuf (~> 4.31) rake (>= 13) + sass-embedded (1.93.2-aarch64-linux-android) + google-protobuf (~> 4.31) sass-embedded (1.93.2-aarch64-linux-gnu) google-protobuf (~> 4.31) sass-embedded (1.93.2-aarch64-linux-musl) google-protobuf (~> 4.31) + sass-embedded (1.93.2-aarch64-mingw-ucrt) + google-protobuf (~> 4.31) + sass-embedded (1.93.2-arm-linux-androideabi) + google-protobuf (~> 4.31) sass-embedded (1.93.2-arm-linux-gnueabihf) google-protobuf (~> 4.31) sass-embedded (1.93.2-arm-linux-musleabihf) google-protobuf (~> 4.31) sass-embedded (1.93.2-arm64-darwin) google-protobuf (~> 4.31) + sass-embedded (1.93.2-riscv64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.93.2-riscv64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.93.2-riscv64-linux-musl) + google-protobuf (~> 4.31) sass-embedded (1.93.2-x86_64-darwin) google-protobuf (~> 4.31) + sass-embedded (1.93.2-x86_64-linux-android) + google-protobuf (~> 4.31) sass-embedded (1.93.2-x86_64-linux-gnu) google-protobuf (~> 4.31) sass-embedded (1.93.2-x86_64-linux-musl) @@ -571,7 +608,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.4) + uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) @@ -583,6 +620,11 @@ GEM concurrent-ruby (~> 1) warden (1.2.9) rack (>= 2.0.9) + warden-jwt_auth (0.8.0) + dry-auto_inject (>= 0.8, < 2) + dry-configurable (>= 0.13, < 2) + jwt (~> 2.1) + warden (~> 1.2) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -610,19 +652,32 @@ GEM PLATFORMS aarch64-linux + aarch64-linux-android aarch64-linux-gnu aarch64-linux-musl + aarch64-mingw-ucrt + arm-linux-androideabi arm-linux-gnu + arm-linux-gnueabihf arm-linux-musl + arm-linux-musleabihf arm64-darwin + riscv64-linux-android + riscv64-linux-gnu + riscv64-linux-musl ruby + x86-linux-gnu + x86-linux-musl x86_64-darwin + x86_64-linux + x86_64-linux-android x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES airbrake amazing_print + bindex bootsnap (>= 1.1.0) brakeman bundler-audit @@ -637,6 +692,7 @@ DEPENDENCIES delayed_job (~> 4.1.0) delayed_job_active_record devise (~> 4.9.3) + devise-jwt directo! faker faraday (>= 2.14.0) @@ -648,6 +704,7 @@ DEPENDENCIES i18n-debug jbuilder (~> 2.11) jsbundling-rails + jsonapi-serializer jwt letter_opener (~> 1.8) letter_opener_web (~> 3.0) diff --git a/app/broadcasts/auctions/update_list_broadcast_service.rb b/app/broadcasts/auctions/update_list_broadcast_service.rb index 680658ff7..c8c55b6f0 100644 --- a/app/broadcasts/auctions/update_list_broadcast_service.rb +++ b/app/broadcasts/auctions/update_list_broadcast_service.rb @@ -16,6 +16,8 @@ def call private + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength def post_call participants = auction.offers.map(&:user) @@ -25,6 +27,22 @@ def post_call locals: { auction:, user:, updated: participants.include?(user) } end + auction_json = { + domain_name: auction.domain_name, + starts_at: auction.starts_at, + ends_at: auction.ends_at, + id: auction.uuid, + highest_bid: auction.currently_winning_offer&.price.to_f, + highest_bidder: auction.currently_winning_offer&.username, + highest_bidder_uuid: auction.currently_winning_offer&.user&.uuid, + min_bids_step: auction.min_bids_step, + auction_type: auction&.platform, + enable_deposit: auction.enable_deposit, + requirement_deposit_in_cents: auction.requirement_deposit_in_cents + } + + ActionCable.server.broadcast('auctions_api', { auction: auction_json }) + broadcast_later 'auctions', 'auctions/streams/updated_list', locals: { auction:, user: nil, updated: false } diff --git a/app/channels/auctions_api_channel.rb b/app/channels/auctions_api_channel.rb new file mode 100644 index 000000000..a292cde43 --- /dev/null +++ b/app/channels/auctions_api_channel.rb @@ -0,0 +1,11 @@ +class AuctionsApiChannel < ApplicationCable::Channel + def subscribed + Rails.logger.info 'Client subscribed to AuctionsChannel' + stream_from 'auctions_api' + end + + def unsubscribed + Rails.logger.info 'Client unsubscribed from AuctionsChannel' + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/app/controllers/api/v1/api_healthcare_controller.rb b/app/controllers/api/v1/api_healthcare_controller.rb new file mode 100644 index 000000000..fd618dabe --- /dev/null +++ b/app/controllers/api/v1/api_healthcare_controller.rb @@ -0,0 +1,37 @@ +class Api::V1::ApiHealthcareController < ApplicationController + respond_to :json + + def index + return render json: { message: 'API is turned off' }, status: 403 unless Feature.mobile_api_enabled? + + app_version = request.headers['App-Version'] + min_version = Feature.minimum_mobile_version + + if app_version.blank? + return render json: { + message: 'App version required', + required_update: true + }, status: 426 # Upgrade Required + end + + if version_outdated?(app_version, min_version) + return render json: { + message: 'App update required', + required_update: true, + min_version:, + current_version: app_version + }, status: 426 # Upgrade Required + end + + render json: { + message: 'API is healthy', + current_version: app_version + } + end + + private + + def version_outdated?(current, minimum) + Gem::Version.new(current) < Gem::Version.new(minimum) + end +end diff --git a/app/controllers/api/v1/auctions_controller.rb b/app/controllers/api/v1/auctions_controller.rb new file mode 100644 index 000000000..55e83c6f7 --- /dev/null +++ b/app/controllers/api/v1/auctions_controller.rb @@ -0,0 +1,22 @@ +module Api + module V1 + class AuctionsController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + + def show + @auction = Auction.find_by!(uuid: params[:auction_id]) + offer = @auction.offer_from_user(current_user.id) + autobider = current_user.autobiders.find_or_initialize_by(domain_name: @auction.domain_name) + billing_profiles = BillingProfile.accessible_by(current_ability).where(user_id: current_user.id) + + render json: { + offer:, + autobider:, + billing_profiles: + }, status: :ok + end + end + end +end diff --git a/app/controllers/api/v1/autobiders_controller.rb b/app/controllers/api/v1/autobiders_controller.rb new file mode 100644 index 000000000..6dcaedff4 --- /dev/null +++ b/app/controllers/api/v1/autobiders_controller.rb @@ -0,0 +1,64 @@ +module Api + module V1 + class AutobidersController < BaseController + respond_to :json + skip_before_action :verify_authenticity_token + before_action :price_must_be_positive, only: :create + + def create + @autobider = Autobider.find_by(user_id: current_user.id, id: strong_params[:id]) + initialize_or_assign_values_for_autobider + + if @autobider.save + process_autobid + render json: { status: 'ok' }, status: :ok + else + render json: { status: 'error', message: @autobider.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + + private + + def process_autobid + auction = Auction.where(domain_name: @autobider.domain_name).order(:created_at).last + autobid!(auction) + call_broadcast_service(auction) + end + + def autobid!(auction) + return if skip_autobid(auction) + + AutobiderService.autobid(auction) + end + + def call_broadcast_service(auction) + Auctions::UpdateListBroadcastService.call({ auction: }) + end + + def initialize_or_assign_values_for_autobider + if @autobider.nil? + @autobider = Autobider.new(strong_params.except(:id).merge(user: current_user, enable: true)) + else + @autobider.assign_initialize_params_for_mobile_api(strong_params) + end + end + + def skip_autobid(auction) + return false if auction.offers.empty? + + offer = auction.offers.order(:updated_at).last + offer.user == @autobider.user + end + + def strong_params + params.require(:autobider).permit(:id, :domain_name, :price) + end + + def price_must_be_positive + return if strong_params[:price].to_d > 0 + + render json: { status: 'error', message: 'Price must be greater than 0' }, status: :unprocessable_entity + end + end + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 000000000..d5582d44f --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,75 @@ +module Api + module V1 + class BaseController < ApplicationController + before_action :api_turn_off + before_action :check_for_authentication + respond_to :json + + before_action :set_locale + + rescue_from StandardError, with: :handle_standard_error + rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found + rescue_from JWT::DecodeError, with: :handle_jwt_decode_error + rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing + + def check_for_authentication + if token = extract_token_from_header + decoded_token = decode_token(token) + payload = decoded_token[0] + + if user_id = payload['sub'] + @current_user = User.find_by(id: user_id) + sign_in(@current_user) if @current_user + end + end + + render json: { errors: 'Unauthorized' }, status: 401 if current_user.nil? + end + + private + + def api_turn_off + return if Feature.mobile_api_enabled? + + render json: { errors: 'API is turned off' }, status: 403 + end + + def decode_token(token) + JWT.decode(token, AuctionCenter::Application.config.customization[:jwt_secret], true, { algorithm: 'HS256' }) + end + + def extract_token_from_header + header = request.headers['HTTP_AUTHORIZATION'] + return nil unless header + + header.split(' ').last if header.start_with?('Bearer ') + end + + def set_locale + return unless user_signed_in? + + I18n.locale = current_user.locale || I18n.default_locale + end + + def handle_standard_error(e) + Rails.logger.error "Error: #{e.message}" + render json: { errors: e.message }, status: :internal_server_error + end + + def handle_record_not_found(e) + Rails.logger.error "Record not found: #{e.message}" + render json: { errors: 'Record not found' }, status: :not_found + end + + def handle_jwt_decode_error(e) + Rails.logger.error "JWT decode error: #{e.message}" + render json: { errors: 'Invalid token' }, status: :unauthorized + end + + def handle_parameter_missing(e) + Rails.logger.info "param is missing or the value is empty - autobider: #{e}" + render json: { errors: 'param is missing or the value is empty' }, status: :bad_request + end + end + end +end diff --git a/app/controllers/api/v1/billing_profiles_controller.rb b/app/controllers/api/v1/billing_profiles_controller.rb new file mode 100644 index 000000000..0dad1b887 --- /dev/null +++ b/app/controllers/api/v1/billing_profiles_controller.rb @@ -0,0 +1,53 @@ +module Api + module V1 + class BillingProfilesController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + + def index + @billing_profiles = current_user.billing_profiles + + render json: { billing_profiles: @billing_profiles } + end + + def update + @billing_profile = current_user.billing_profiles.find(params[:id]) + + if @billing_profile.update(billing_profile_params) + render json: { billing_profile: @billing_profile } + else + render json: { errors: @billing_profile.errors }, status: 422 + end + end + + def create + @billing_profile = current_user.billing_profiles.new(billing_profile_params) + + if @billing_profile.save + render json: { billing_profile: @billing_profile } + else + render json: { errors: @billing_profile.errors }, status: 422 + end + end + + def destroy + @billing_profile = current_user.billing_profiles.find(params[:id]) + + if @billing_profile.deletable? + @billing_profile.destroy! + render json: { message: 'Billing profile deleted successfully' }, status: :ok + else + render json: { errors: @billing_profile.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def billing_profile_params + params.require(:billing_profile).permit(:name, :vat_code, :street, :city, :state, :postal_code, + :alpha_two_country_code, :uuid) + end + end + end +end diff --git a/app/controllers/api/v1/invoices/oneoff_payments_controller.rb b/app/controllers/api/v1/invoices/oneoff_payments_controller.rb new file mode 100644 index 000000000..dfd3791a3 --- /dev/null +++ b/app/controllers/api/v1/invoices/oneoff_payments_controller.rb @@ -0,0 +1,31 @@ +module Api + module V1 + module Invoices + class OneoffPaymentsController < BaseController + respond_to :json + skip_before_action :verify_authenticity_token + before_action :set_invoice, only: [:create] + + def create + response = EisBilling::OneoffService.call(invoice_number: @invoice.number.to_s, + customer_url: mobile_payments_deposit_callback_url, + amount: @invoice.total.to_f) + + if response.result? + render json: { oneoff_redirect_link: response.instance['oneoff_redirect_link'] } + else + render json: { errors: response.errors } + end + end + + private + + def set_invoice + @invoice = Invoice.find_by(uuid: params[:id]) + + raise ActiveRecord::RecordNotFound unless @invoice + end + end + end + end +end diff --git a/app/controllers/api/v1/invoices/pay_deposits_controller.rb b/app/controllers/api/v1/invoices/pay_deposits_controller.rb new file mode 100644 index 000000000..effce7d67 --- /dev/null +++ b/app/controllers/api/v1/invoices/pay_deposits_controller.rb @@ -0,0 +1,34 @@ +module Api + module V1 + module Invoices + class PayDepositsController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + before_action :set_auction, only: [:create] + + def create + response = EisBilling::PayDepositService.call(amount: @auction.deposit, + customer_url: mobile_payments_deposit_callback_url, + description:) + if response.result? + render json: { oneoff_redirect_link: response.instance['oneoff_redirect_link'] } + else + render json: { errors: response.errors } + end + end + + private + + def description + "auction_deposit #{@auction.domain_name}, user_uuid #{current_user.uuid}, " \ + "user_email #{current_user.email}" + end + + def set_auction + @auction = Auction.find_by(uuid: params[:id]) + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb new file mode 100644 index 000000000..a7c608513 --- /dev/null +++ b/app/controllers/api/v1/invoices_controller.rb @@ -0,0 +1,52 @@ +module Api + module V1 + class InvoicesController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + + # rubocop:disable Metrics/AbcSize + def index + @issued_invoices = invoices_list_by_status(Invoice.statuses[:issued]) + @paid_invoices = invoices_list_by_status(Invoice.statuses[:paid]) + @cancelled_payable_invoices = invoices_list_by_status(Invoice.statuses[:cancelled]).with_ban + @cancelled_expired_invoices = invoices_list_by_status(Invoice.statuses[:cancelled]).without_ban + @deposit_paid = current_user.domain_participate_auctions.includes(:auction).order(created_at: :desc) + + render json: { + issued_invoices: invoices_with_auction_name(@issued_invoices), + paid_invoices: invoices_with_auction_name(@paid_invoices), + cancelled_payable_invoices: invoices_with_auction_name(@cancelled_payable_invoices), + cancelled_expired_invoices: invoices_with_auction_name(@cancelled_expired_invoices), + deposit_paid: @deposit_paid.map do |dpa| + { + id: dpa.id, + created_at: dpa.created_at, + status: dpa.status, + invoice_number: dpa.invoice_number, + domain_name: dpa.auction.domain_name, + auction_id: dpa.auction_id, + auction_name: dpa.auction.domain_name + } + end + } + end + + private + + def invoices_list_by_status(status) + Invoice.accessible_by(current_ability) + .includes(result: :auction) + .where(user_id: current_user.id) + .where(status:) + .order(due_date: :desc) + end + + def invoices_with_auction_name(invoices) + invoices.map do |invoice| + invoice.as_json.merge(auction_name: invoice.result&.auction&.domain_name) + end + end + end + end +end diff --git a/app/controllers/api/v1/offers_controller.rb b/app/controllers/api/v1/offers_controller.rb new file mode 100644 index 000000000..84617e5a4 --- /dev/null +++ b/app/controllers/api/v1/offers_controller.rb @@ -0,0 +1,86 @@ +module Api + module V1 + class OffersController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + before_action :set_auction, only: [:create] + + def index + offers = Offer.includes(:auction) + .includes(:result) + .where(user_id: current_user) + .order('auctions.ends_at DESC') + + # price with tax + render json: offers.as_json( + include: %i[auction billing_profile], + methods: %i[auction_status api_price api_total api_bidders] + ) + end + + def create + BillingProfile.create_default_for_user(current_user.id) + @offer = initialize_or_assign_price_to_offer + + if @offer.save + process_english_auction if @auction.english? + render json: { status: 'ok' }, status: :ok + else + Rails.logger.info "Offer errors (details): #{@offer.errors.details}" + render json: { status: 'error', errors: @offer.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_auction + @auction = Auction.find_by(uuid: offer_params[:auction_id]) + return if @auction.present? + + render_error("Auction with #{offer_params[:auction_id]} uuid not found") + end + + def process_english_auction + call_broadcast + @auction.update_minimum_bid_step(offer_params[:price].to_f) + AutobiderService.autobid(@auction) + @auction.update_ends_at(@offer) + end + + def call_broadcast + Auctions::UpdateListBroadcastService.call({ auction: @auction }) + end + + def initialize_or_assign_price_to_offer + offer = @auction.offer_from_user(current_user.id) + + if offer.nil? + offer = Offer.new( + auction: @auction, + user: current_user, + cents: Money.from_amount(offer_params[:price]).cents, + billing_profile:, + username: @auction.english? ? Username::GenerateUsernameService.new.call : nil + ) + else + offer.cents = Money.from_amount(offer_params[:price]).cents + end + + offer + end + + def billing_profile + if offer_params[:billing_profile_id].present? + current_user.billing_profiles.find_by(id: offer_params[:billing_profile_id]) + else + current_user.billing_profiles.first + end + end + + def offer_params + params.require(:bid).permit(:auction_id, :price, :billing_profile_id) + end + end + end +end diff --git a/app/controllers/api/v1/profiles/passwords_controller.rb b/app/controllers/api/v1/profiles/passwords_controller.rb new file mode 100644 index 000000000..e09543f26 --- /dev/null +++ b/app/controllers/api/v1/profiles/passwords_controller.rb @@ -0,0 +1,31 @@ +module Api + module V1 + module Profiles + class PasswordsController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + + def update + puts params_for_update + + if current_user.valid_password?(params[:user][:current_password]) + if current_user.update(params_for_update) + bypass_sign_in(current_user) + render json: current_user, status: :ok + else + Rails.logger.info current_user.errors.inspect + render json: current_user.errors, status: :unprocessable_entity + end + else + render json: { errors: [t('.incorrect_password')] }, status: :unprocessable_entity + end + end + + def params_for_update + params.require(:user).permit(:password, :password_confirmation) + end + end + end + end +end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb new file mode 100644 index 000000000..8d4d7e790 --- /dev/null +++ b/app/controllers/api/v1/profiles_controller.rb @@ -0,0 +1,52 @@ +module Api + module V1 + class ProfilesController < BaseController + respond_to :json + + skip_before_action :verify_authenticity_token + skip_before_action :check_for_authentication, only: %i[create] + + def update + if current_user.update(params_for_update) + render json: current_user, status: :ok + else + Rails.logger.info current_user.errors.inspect + render json: { errors: user.errors.to_hash(true) }, status: :unprocessable_entity + end + end + + def create + user = initialize_user(params_for_create) + set_locale_for user + + if user.save + # sign_in(User, user) + render json: user, status: :created + else + Rails.logger.info user.errors.inspect + render json: { errors: user.errors.to_hash(true) }, status: :unprocessable_entity + end + end + + private + + def initialize_user(params_for_create) + User.new(params_for_create) + end + + def params_for_create + params.require(:user).permit(:email, :password, :password_confirmation, :country_code, :alpha_two_country_code, + :given_names, :surname, :mobile_phone, :accepts_terms_and_conditions, + :locale, :daily_summary, :identity_code) + end + + def set_locale_for(user) + I18n.locale = user.locale || I18n.default_locale + end + + def params_for_update + params.require(:user).permit(:email, :country_code, :given_names, :surname, :mobile_phone) + end + end + end +end diff --git a/app/controllers/api/v1/stream_names_controller.rb b/app/controllers/api/v1/stream_names_controller.rb new file mode 100644 index 000000000..7af769068 --- /dev/null +++ b/app/controllers/api/v1/stream_names_controller.rb @@ -0,0 +1,14 @@ +module Api + module V1 + class StreamNamesController < BaseController + skip_before_action :check_for_authentication + skip_before_action :verify_authenticity_token + + def show + signed_stream_name = Turbo::StreamsChannel.signed_stream_name 'auctions' + + render json: { signed_stream_name: } + end + end + end +end diff --git a/app/controllers/api/v1/tara_auth_sessions_controller.rb b/app/controllers/api/v1/tara_auth_sessions_controller.rb new file mode 100644 index 000000000..9307747d4 --- /dev/null +++ b/app/controllers/api/v1/tara_auth_sessions_controller.rb @@ -0,0 +1,47 @@ +module Api + module V1 + class TaraAuthSessionsController < BaseController + respond_to :json + skip_before_action :check_for_authentication + skip_before_action :verify_authenticity_token + + def create + received_hmac = params[:token] + message = Rails.configuration.customization[:mobile_secret_word] + + unless valid_hmac?(received_hmac, message) + render_error('Invalid HMAC') + return + end + + identity = params[:identity_code] + first_name = params[:first_name] + last_name = params[:last_name] + country_code = params[:country_code] + + @user = User.find_by(identity_code: identity, country_code:) + @user.update(given_names: first_name, surname: last_name) if @user.present? + + if @user.present? + sign_in(User, @user) + render json: { message: 'User signed in', user: @user, token: current_token }, status: :ok + else + render json: { error: 'User not found' }, status: :not_found + end + end + + private + + def valid_hmac?(received_hmac, message) + secret_key = Rails.configuration.customization[:mobile_secret_key] + digest = OpenSSL::Digest.new('sha256') + hmac = OpenSSL::HMAC.digest(digest, secret_key, message) + Base64.strict_encode64(hmac) == received_hmac + end + + def current_token + request.env['warden-jwt_auth.token'] + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 797f126ef..34d283a25 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :clear_flash, :store_user_location!, if: :storable_location? before_action :set_locale + before_action :notifications_for_header content_security_policy do |policy| diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 84e7577d1..d194186df 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -1,8 +1,13 @@ module Auth class SessionsController < Devise::SessionsController + include RackSessionFix include InvalidUserDataHelper + after_action :set_invalid_data_flag_in_session, only: [:create] + skip_before_action :verify_authenticity_token + respond_to :html, :json + def create super end diff --git a/app/controllers/concerns/rack_session_fix.rb b/app/controllers/concerns/rack_session_fix.rb new file mode 100644 index 000000000..c062d6156 --- /dev/null +++ b/app/controllers/concerns/rack_session_fix.rb @@ -0,0 +1,15 @@ +module RackSessionFix + extend ActiveSupport::Concern + class FakeRackSession < Hash + def enabled? + false + end + end + included do + before_action :set_fake_rack_session_for_devise + private + def set_fake_rack_session_for_devise + request.env['rack.session'] ||= FakeRackSession.new + end + end +end diff --git a/app/controllers/eis_billing/payment_status_controller.rb b/app/controllers/eis_billing/payment_status_controller.rb index 7412469a2..f2f43e604 100644 --- a/app/controllers/eis_billing/payment_status_controller.rb +++ b/app/controllers/eis_billing/payment_status_controller.rb @@ -5,7 +5,7 @@ def update unless is_deposit invoice = ::Invoice.find_by(number: params[:order_reference]) - define_payment_option(invoice: invoice) + define_payment_option(invoice:) end respond_to do |format| @@ -31,7 +31,7 @@ def check_for_deposit(params) def define_payment_option(invoice:) if params[:invoice_number_collection].nil? - payment_process(invoice: invoice) unless invoice.nil? + payment_process(invoice:) unless invoice.nil? else pay_mulitply(params[:invoice_number_collection]) end @@ -42,7 +42,7 @@ def pay_mulitply(data) data.each do |d| invoice = ::Invoice.find_by(number: d[:number]) - payment_process(invoice: invoice) + payment_process(invoice:) end end diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index fb14eaf44..5563bc387 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -10,6 +10,8 @@ class EnglishOffersController < ApplicationController # order is important include EnglishOffers::Offerable + skip_before_action :verify_authenticity_token + include RecaptchaValidatable recaptcha_action 'english_offer' include OfferNotifable diff --git a/app/controllers/linkpay_controller.rb b/app/controllers/linkpay_controller.rb index 9f1141f39..06b608c25 100644 --- a/app/controllers/linkpay_controller.rb +++ b/app/controllers/linkpay_controller.rb @@ -2,8 +2,16 @@ class LinkpayController < ApplicationController skip_before_action :verify_authenticity_token, only: %i[callback] def callback + Rails.logger.info('=============') + Rails.logger.info(linkpay_params) + Rails.logger.info('=============') + EisBilling::SendCallbackService.call(reference_number: linkpay_params[:payment_reference]) + Rails.logger.info('=============') + Rails.logger.info(' After Send callback') + Rails.logger.info('=============') + redirect_to invoices_path(state: 'payment') end diff --git a/app/controllers/mobile_payments_controller.rb b/app/controllers/mobile_payments_controller.rb new file mode 100644 index 000000000..f7f02e587 --- /dev/null +++ b/app/controllers/mobile_payments_controller.rb @@ -0,0 +1,25 @@ +class MobilePaymentsController < ApplicationController + skip_before_action :verify_authenticity_token, only: %i[callback] + + def callback + Rails.logger.info '------ SendCallbackService ----' + EisBilling::SendCallbackService.call(reference_number: linkpay_params[:payment_reference]) + Rails.logger.info '------ SendCallbackService ----' + + redirect_to mobile_payments_path + end + + def deposit_callback + EisBilling::SendCallbackService.call(reference_number: linkpay_params[:payment_reference]) + + redirect_to mobile_payments_path + end + + def index; end + + private + + def linkpay_params + params.permit(:order_reference, :payment_reference) + end +end diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index ab15b2589..45d3e6c92 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -4,7 +4,12 @@ class OffersController < ApplicationController include Offerable before_action :set_offer, only: %i[show edit update destroy] - before_action :authorize_offer_for_user, except: %i[new index create delete] + before_action :check_for_ban, only: :create + before_action :authorize_phone_confirmation + before_action :authorize_offer_for_user, except: %i[new index create] + skip_before_action :verify_authenticity_token + + respond_to :html, :json include RecaptchaValidatable recaptcha_action 'offer' @@ -46,6 +51,11 @@ def create def index offers = Offer.highest_per_auction_for_user(current_user.id).search(params) @pagy, @offers = pagy(offers, items: params[:per_page] ||= 15) + + respond_to do |format| + format.html + format.json { render json: @offers.as_json(include: [:auction, :billing_profile]) } + end end # GET /offers/aa450f1a-45e2-4f22-b2c3-f5f46b5f906b diff --git a/app/models/auction.rb b/app/models/auction.rb index a920aca12..ebfdcdd12 100644 --- a/app/models/auction.rb +++ b/app/models/auction.rb @@ -32,10 +32,13 @@ class Auction < ApplicationRecord # rubocop:disable Metrics delegate :size, to: :offers, prefix: true def update_list_broadcast + return if blind? + Auctions::UpdateListBroadcastService.call({ auction: self }) end def update_offer_broadcast + return if blind? Auctions::UpdateOfferBroadcastService.call({ auction: self }) end diff --git a/app/models/autobider.rb b/app/models/autobider.rb index 5dc35e77f..132d824ff 100644 --- a/app/models/autobider.rb +++ b/app/models/autobider.rb @@ -2,7 +2,14 @@ class Autobider < ApplicationRecord belongs_to :user validates :cents, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :domain_name, presence: true validates :domain_name, uniqueness: { scope: :user_id } + validate :validate_domain_name_for_exists + + def assign_initialize_params_for_mobile_api(strong_params) + self.price = strong_params[:price] + self.enable = true + end def price Money.new(cents, Setting.find_by(code: 'auction_currency').retrieve) @@ -12,4 +19,11 @@ def price=(value) price = Money.from_amount(value.to_d, Setting.find_by(code: 'auction_currency').retrieve) self.cents = price.cents.positive? ? price.cents : nil end + + def validate_domain_name_for_exists + domain = Auction.where(domain_name: domain_name).order(:created_at).last + return if domain.present? + + errors.add(:domain_name, I18n.t('autobider.domain_name.does_not_exist')) + end end diff --git a/app/models/feature.rb b/app/models/feature.rb index 5c4f9a5a8..b8968ee17 100644 --- a/app/models/feature.rb +++ b/app/models/feature.rb @@ -15,4 +15,14 @@ def self.open_ai_integration_enabled? !!AuctionCenter::Application.config.customization[:openai] &.compact&.fetch(:enabled, false) end + + def self.mobile_api_enabled? + !!AuctionCenter::Application.config.customization[:mobile_api] + &.compact&.fetch(:enabled, false) + end + + def self.minimum_mobile_version + AuctionCenter::Application.config.customization[:mobile_api] + &.compact&.fetch(:minimum_version, '1.0.0') + end end diff --git a/app/models/offer.rb b/app/models/offer.rb index 335c8b7b9..01af80d84 100644 --- a/app/models/offer.rb +++ b/app/models/offer.rb @@ -105,12 +105,43 @@ def price=(value) def total return price * (DEFAULT_PRICE_VALUE + billing_profile.vat_rate) if billing_profile.present? - if user&.country_code == 'EE' || user&.country_code.nil? - default_vat = Setting.find_by(code: :estonian_vat_rate).retrieve - else - default_vat = Countries.vat_rate_from_alpha2_code(user.country_code) + default_vat = if user&.country_code == 'EE' || user&.country_code.nil? + Setting.find_by(code: :estonian_vat_rate).retrieve + else + Countries.vat_rate_from_alpha2_code(user.country_code) + end + + price * (DEFAULT_PRICE_VALUE + (Invoice.find_by(result:)&.vat_rate || default_vat)) + end + + def auction_status + return 'you_won' if auction.finished? && result + return 'you_lost' if auction.finished? && auction.result && !result + + if auction.english? + return 'you_are_winning' if auction.currently_winning_offer == self + + return 'you_are_loosing' end - price * (DEFAULT_PRICE_VALUE + (Invoice.find_by(result: result)&.vat_rate || default_vat)) + 'still_in_progress' + end + + def api_total + total.to_d + end + + def api_price + price.to_d + end + + def api_bidders + auction.offers.map do |offer| + { + username: offer.username, + price: offer.price.to_d, + updated_at: offer.updated_at + } + end end end diff --git a/app/models/payment_orders/every_pay.rb b/app/models/payment_orders/every_pay.rb index 42f3db80d..7b3e99742 100644 --- a/app/models/payment_orders/every_pay.rb +++ b/app/models/payment_orders/every_pay.rb @@ -49,6 +49,10 @@ def form_url # Perform necessary checks and mark the invoice as paid def mark_invoice_as_paid + Rails.logger.info('============= settled_payment?') + Rails.logger.info(settled_payment?) + Rails.logger.info('=============') + return unless settled_payment? response.with_indifferent_access @@ -65,6 +69,9 @@ def mark_invoice_as_paid # Check if the intermediary reports payment as settled and we can expect money on # our accounts def settled_payment? + Rails.logger.info('============= IN SETTLED PAYMENT? payment_state') + Rails.logger.info(response) + Rails.logger.info('=============') SUCCESSFUL_PAYMENT.include?(response['payment_state']) end @@ -91,13 +98,13 @@ def base_params api_username: USER, account_id: ACCOUNT_ID, timestamp: Time.now.to_i.to_s, - callback_url: callback_url, + callback_url:, customer_url: return_url, amount: invoices_total&.format(symbol: nil, thousands_separator: false, decimal_mark: '.'), order_reference: SecureRandom.hex(15), transaction_type: 'charge', locale: language, - hmac_fields: '', + hmac_fields: '' }.with_indifferent_access end end diff --git a/app/models/user.rb b/app/models/user.rb index 03a9b0ed0..ac3b74c1e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord include Bannable include ReferenceNo + include Devise::JWT::RevocationStrategies::JTIMatcher PARTICIPANT_ROLE = 'participant'.freeze ADMINISTATOR_ROLE = 'administrator'.freeze @@ -12,7 +13,7 @@ class User < ApplicationRecord TARA_PROVIDER = 'tara'.freeze devise :database_authenticatable, :recoverable, :rememberable, :validatable, :confirmable, - :timeoutable + :timeoutable, :jwt_authenticatable, jwt_revocation_strategy: self alias_attribute :country_code, :alpha_two_country_code diff --git a/app/services/eis_billing/send_callback_service.rb b/app/services/eis_billing/send_callback_service.rb index cf87ea254..d8064d693 100644 --- a/app/services/eis_billing/send_callback_service.rb +++ b/app/services/eis_billing/send_callback_service.rb @@ -8,7 +8,7 @@ def initialize(reference_number:) end def self.call(reference_number:) - new(reference_number: reference_number).call + new(reference_number:).call end def call diff --git a/app/views/auctions/index.json.jbuilder b/app/views/auctions/index.json.jbuilder index 2c8ebbc4f..de1e94788 100644 --- a/app/views/auctions/index.json.jbuilder +++ b/app/views/auctions/index.json.jbuilder @@ -4,4 +4,18 @@ json.array! @auctions_list do |auction| json.ends_at auction.ends_at.utc json.auction_type auction&.platform json.id auction.uuid + json.highest_bid auction.currently_winning_offer&.price.to_f + json.highest_bidder auction.currently_winning_offer&.username + json.highest_bidder_uuid auction.currently_winning_offer&.user&.uuid + json.min_bids_step auction.min_bids_step.to_f + json.auction_type auction&.platform + json.enable_deposit auction.enable_deposit + json.requirement_deposit_in_cents auction.requirement_deposit_in_cents + json.user_deposit_paid do + if current_user + auction.domain_participate_auctions.exists?(user_id: current_user.id, status: 'paid') + else + false + end + end end diff --git a/app/views/billing_profiles/index.json.jbuilder b/app/views/billing_profiles/index.json.jbuilder new file mode 100644 index 000000000..1414faf3d --- /dev/null +++ b/app/views/billing_profiles/index.json.jbuilder @@ -0,0 +1,11 @@ +json.array! @billing_profiles do |billing_profile| + json.id billing_profile.uuid + json.user_id billing_profile.user_id + json.name billing_profile.name + json.vat_code billing_profile.vat_code + json.street billing_profile.street + json.city billing_profile.city + json.state billing_profile.state + json.postal_code billing_profile.postal_code + json.alpha_two_country_code billing_profile.alpha_two_country_code +end diff --git a/app/views/mobile_payments/index.html.erb b/app/views/mobile_payments/index.html.erb new file mode 100644 index 000000000..7c23e87d4 --- /dev/null +++ b/app/views/mobile_payments/index.html.erb @@ -0,0 +1,24 @@ +
+
+
+
+ + + +
+ +

<%= t('mobile_payments.payment_successful') %>

+

<%= t('mobile_payments.payment_processed') %>

+ +
+ <%= t('mobile_payments.close_window_instruction') %> +
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100644 index 000000000..9c27725ba --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,94 @@ +#!/bin/bash +set -e + +# Remove a potentially pre-existing server.pid for Rails +rm -f /opt/webapps/app/tmp/pids/server.pid + +# Ensure proper environment variables are set for Rails in production mode +if [ "$RAILS_ENV" == "production" ] || [ "$RAILS_ENV" == "staging" ]; then + # Check if the master key is provided + if [ -n "$RAILS_MASTER_KEY" ]; then + echo "RAILS_MASTER_KEY environment variable is set. Using Rails credentials system." + # If no master key, check for secret key base + elif [ -n "$SECRET_KEY_BASE" ]; then + echo "SECRET_KEY_BASE environment variable is set. Using direct secret key configuration." + else + echo "WARNING: Neither RAILS_MASTER_KEY nor SECRET_KEY_BASE environment variables are set." + echo "Generating a temporary SECRET_KEY_BASE. This is okay for testing but NOT recommended for real production use." + export SECRET_KEY_BASE=$(openssl rand -hex 64) + fi +fi + +# Check for database connection settings - using the APP_DBHOST variable from database.yml +if [[ -n "$DATABASE_URL" || -n "$APP_DBHOST" ]]; then + # Get database host from DATABASE_URL or APP_DBHOST + if [ -n "$DATABASE_URL" ]; then + # Extract host from DATABASE_URL + DB_HOST=$(echo $DATABASE_URL | awk -F[@//] '{print $4}' | cut -d':' -f1) + else + DB_HOST=$APP_DBHOST + fi + + # Get database user from APP_DBUSER or default to postgres + DB_USER=${APP_DBUSER:-postgres} + + # Wait for PostgreSQL to be available + echo "Checking PostgreSQL connection to $DB_HOST..." + until pg_isready -h $DB_HOST -p ${APP_DBPORT:-5432} -U $DB_USER 2>/dev/null; do + echo "Waiting for PostgreSQL to be available..." + sleep 2 + done + echo "PostgreSQL is available." + + # Set up database if needed (useful for initial deployments) + if [ "${DB_SETUP:-false}" = "true" ]; then + echo "Setting up database..." + bundle exec rails db:setup + fi + + # Run database migrations if needed + if [ "${AUTO_MIGRATE:-false}" = "true" ]; then + echo "Running database migrations..." + bundle exec rails db:migrate + fi +else + echo "Database connection settings not found. Skipping database checks." +fi + +# Check for Redis connection if Redis is used +if [ -n "$REDIS_URL" ]; then + echo "Redis URL is set to $REDIS_URL" + # Attempting Redis connection + if command -v redis-cli &> /dev/null; then + echo "Checking Redis connection..." + REDIS_HOST=$(echo $REDIS_URL | sed -E 's/^redis:\/\/(.*):([0-9]+)\/([0-9]+)$/\1/') + REDIS_PORT=$(echo $REDIS_URL | sed -E 's/^redis:\/\/(.*):([0-9]+)\/([0-9]+)$/\2/') + + if [ -n "$REDIS_HOST" ] && [ -n "$REDIS_PORT" ]; then + until redis-cli -h $REDIS_HOST -p $REDIS_PORT ping > /dev/null 2>&1; do + echo "Waiting for Redis to become available..." + sleep 2 + done + echo "Redis is available." + else + echo "Could not parse Redis host and port from REDIS_URL. Skipping Redis connection check." + fi + else + echo "redis-cli not available. Skipping Redis connection check." + fi +fi + +# Prepare assets if needed (precompilation should have been done during build) +if [ "${PRECOMPILE_ASSETS:-false}" = "true" ] && { [ ! -d /opt/webapps/app/public/assets ] || [ -z "$(ls -A /opt/webapps/app/public/assets)" ]; }; then + echo "Precompiling assets..." + bundle exec rails assets:precompile +fi + +# Create readiness indicator for Kubernetes probes +mkdir -p /opt/webapps/app/tmp/k8s +touch /opt/webapps/app/tmp/k8s/ready + +echo "Auction Center application is ready to serve requests!" + +# Execute the container's main process (what's set as CMD in the Dockerfile) +exec "$@" \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 0d547d8f3..72ca33cc5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,6 +9,22 @@ module AuctionCenter class Application < Rails::Application + + class RequestLoggerMiddleware + def initialize(app) + @app = app + end + + def call(env) + Rails.logger.info "Request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + Rails.logger.info "Headers: #{env.select { |k, v| k.start_with? 'HTTP_' }}" + Rails.logger.info "Body: #{env['rack.input'].read}" + env['rack.input'].rewind + + @app.call(env) + end + end + # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 config.active_support.to_time_preserves_timezone = :zone @@ -18,6 +34,7 @@ class Application < Rails::Application config.active_model.i18n_customize_full_message = true config.autoload_paths += %W(#{config.root}/app/models/concerns) + config.middleware.insert_before 0, RequestLoggerMiddleware # config.autoload_paths += Dir[Rails.root.join('app', 'presenters', '**/')] # config.autoload_paths += Dir[Rails.root.join('app', 'broadcasts', '**/')] diff --git a/config/customization.yml.sample b/config/customization.yml.sample index 89192daff..8e489be95 100644 --- a/config/customization.yml.sample +++ b/config/customization.yml.sample @@ -21,6 +21,13 @@ default: &default mobile_sms_sent_time_limit_in_minutes: 1 auction_filter_available: false ended_auctions_link_available: false + jwt_secret: '' + mobile_secret_key: '' + mobile_secret_word: '' + + mobile_api: + enabled: false + minimum_version: '1.0.0' mailer: # Host to which links from emails should redirect to @@ -88,11 +95,18 @@ default: &default password: 'messente_password' tara: + scope: > + openid + idcard + mid + smartid + discovery: true host: 'tara-test.ria.ee' issuer: 'https://tara-test.ria.ee' identifier: 'identifier' secret: 'secret' redirect_uri: 'redirect_url' + tara_keys: '{"kty":"RSA","n":"iVKwG5cTHFx60wYLndRLlzlvH9m2XsVaWh0LQFcvQBCcUMXjCYQRJ22sLjAz6fvig83dWcoKQVanZfzNGAqG_I54LIVT6oUZxFgCA1cyFKELaCqnpzQa3m7CBOklQUV7Z6Dtj1bMJiMIaEv8lzhtKmqkC6o2xjTWIbVCBublwF0DH5SsVdeX-kC4aJtYCbhsuYuzrn4VpR33NuvLxOBPHDVCMYImxlYU337uf6DjmdZMV96ODqP7E9iMS3GWk_MJEzrgLU7_7JiO3OWtkBUNspZ7pgNdIc6OQ5ZASfWsUufS44kt1fNmPqowklHCRNqcnFOx0lc7ya_VlCdXV6Qfew","e":"AQAB"}' keys: kty: 'RSA' kid: 'public:xWbbVoYq9EwMqphp' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index b00090533..8c97457d9 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -29,6 +29,13 @@ def skip_format? config.navigational_formats = ['*/*', :html, :turbo_stream] +config.jwt do |jwt| + jwt.secret = AuctionCenter::Application.config.customization[:jwt_secret] + jwt.dispatch_requests = [ ['POST', %r{^/sessions/sign_in$}], ['POST', %r{^/api/v1/tara_auth_session}] ] + jwt.revocation_requests = [ ['DELETE', %r{^/sessions/sign_out$}] ] + jwt.expiration_time = 30.minutes.to_i +end + config.warden do |manager| manager.failure_app = TurboFailureApp # manager.intercept_401 = false @@ -204,7 +211,7 @@ def skip_format? # ==> Configuration for :timeoutable # The time you want to timeout the user session without activity. After this # time the user will be asked for credentials again. Default is 30 minutes. - config.timeout_in = 20.minutes + config.timeout_in = 5.minutes # ==> Configuration for :lockable # Defines which strategy will be used to lock an account. diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 928538f90..8b40ce3ea 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -11,6 +11,7 @@ # signing_keys = AuctionCenter::Application.config.customization.dig(:tara, :keys).to_json # signing_keys = AuctionCenter::Application.config.customization.dig(:tara, :tara_keys).to_json + issuer = AuctionCenter::Application.config.customization.dig(:tara, :issuer) host = AuctionCenter::Application.config.customization.dig(:tara, :host) identifier = AuctionCenter::Application.config.customization.dig(:tara, :identifier) diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb index 5914e2ee3..0d35d8772 100644 --- a/config/initializers/pagy.rb +++ b/config/initializers/pagy.rb @@ -7,6 +7,3 @@ { locale: 'et', filepath: "#{Rails.root}/config/locales/pagy.et.yml" }) # default :empty_page (other options :last_page and :exception ) -Pagy::DEFAULT[:overflow] = :last_page -# Pagy::DEFAULT[:size] = [1,2,2,1] -Pagy::DEFAULT.freeze diff --git a/config/locales/autobider.en.yml b/config/locales/autobider.en.yml new file mode 100644 index 000000000..426e77bb2 --- /dev/null +++ b/config/locales/autobider.en.yml @@ -0,0 +1,4 @@ +en: + autobider: + domain_name: + does_not_exist: "Domain name does not exist" \ No newline at end of file diff --git a/config/locales/autobider.et.yml b/config/locales/autobider.et.yml new file mode 100644 index 000000000..6f57d2aba --- /dev/null +++ b/config/locales/autobider.et.yml @@ -0,0 +1,4 @@ +et: + autobider: + domain_name: + does_not_exist: "Domaeni nimi ei ole olemas" diff --git a/config/locales/en.yml b/config/locales/en.yml index 613fa6a9b..7ec242b20 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -111,7 +111,7 @@ en: created_at: "User created at" updated_at: "Updated at" - is_invalid: "%{attribute} is invalid" + is_invalid: "is invalid" invalid: "invalid" value_is_not_safe: | Please note that the only characters allowed are UTF-characters of english/estonian alphabet, diff --git a/config/locales/et.yml b/config/locales/et.yml index 5ef1e4881..aad45d877 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -88,7 +88,7 @@ et: created_at: "Kasutaja loodud" updated_at: "Muudetud" - is_invalid: "vale" + is_invalid: "on vigane" invalid: "vale" value_is_not_safe: | Nime ja aadressi andmetes on lubatud kasutada vaid inglise ja eesti tähestiku tähti, numbreid, diff --git a/config/locales/mobile_payments.en.yml b/config/locales/mobile_payments.en.yml new file mode 100644 index 000000000..128c31601 --- /dev/null +++ b/config/locales/mobile_payments.en.yml @@ -0,0 +1,6 @@ +en: + mobile_payments: + payment_successful: "Payment Successful" + payment_processed: "Your payment has been processed successfully." + close_window_instruction: "You can now close this window and return to the application." + close_window: "Close Window" diff --git a/config/locales/mobile_payments.et.yml b/config/locales/mobile_payments.et.yml new file mode 100644 index 000000000..605a69489 --- /dev/null +++ b/config/locales/mobile_payments.et.yml @@ -0,0 +1,6 @@ +et: + mobile_payments: + payment_successful: "Makse õnnestus" + payment_processed: "Teie makse on edukalt töödeldud." + close_window_instruction: "Võite nüüd selle akna sulgeda ja naasta rakendusse." + close_window: "Sulge aken" diff --git a/config/routes.rb b/config/routes.rb index b27a451cc..cfe42fb5b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,30 @@ end # mount ActionCable.server => '/cable' + namespace :api do + namespace :v1 do + get 'healthcare', to: 'api_healthcare#index' + resource :stream_name, only: :show + resources :offers, only: [:create, :index] + resource :autobiders, only: :create + resource :tara_auth_session, only: :create + resource :auctions, only: :show + resource :profiles, only: %i[update create] do + scope module: :profiles do + resource :passwords, only: :update + end + end + resources :invoices, only: :index + + scope module: :invoices do + resource :pay_deposits, only: :create + resource :oneoff_payments, only: :create + end + + resources :billing_profiles, only: %i[index update create destroy] + end + end + get 'unsubscribe/unsubscribe' patch 'unsubscribe/update' root to: 'auctions#index' @@ -77,6 +101,7 @@ match '/auth/tara/cancel', via: %i[get post delete], to: 'auth/tara#cancel', as: :tara_cancel match '/auth/tara/create', via: [:post], to: 'auth/tara#create', as: :tara_create + match '/api/v1/tara_auth_sessions', via: [:post], to: 'api/v1/tara_auth_sessions#create' end devise_for :users, path: 'sessions', @@ -116,10 +141,6 @@ resources :payment_orders, only: %i[new show create], shallow: true, param: :uuid do member do - # get 'return' - # put 'return' - # post 'return' - post 'callback' end end @@ -128,6 +149,10 @@ match '/linkpay_callback', via: %i[get], to: 'linkpay#callback', as: :linkpay_callback match '/linkpay_deposit_callback', via: %i[get], to: 'linkpay#deposit_callback', as: :deposit_callback + match '/mobile_payments_callback', via: %i[get], to: 'mobile_payments#callback', as: :mobile_payments_callback + match '/mobile_payments_deposit_callback', via: %i[get], to: 'mobile_payments#deposit_callback', as: :mobile_payments_deposit_callback + get '/mobile_payments', to: 'mobile_payments#index', as: :mobile_payments + resource :locale, only: :update resources :offers, only: :index resources :results, only: :show, param: :uuid diff --git a/db/migrate/20231013110924_add_jti_to_users.rb b/db/migrate/20231013110924_add_jti_to_users.rb new file mode 100644 index 000000000..cf4d56a7b --- /dev/null +++ b/db/migrate/20231013110924_add_jti_to_users.rb @@ -0,0 +1,6 @@ +class AddJtiToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :jti, :string + add_index :users, :jti, unique: true + end +end diff --git a/db/structure.sql b/db/structure.sql index 50070abff..10ce18ffb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -3203,4 +3203,3 @@ INSERT INTO "schema_migrations" (version) VALUES ('20180919104523'), ('20180907083511'), ('20180829130641'); - diff --git a/log/.keep b/log/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/controllers/api/v1/auctions_controller_test.rb b/test/controllers/api/v1/auctions_controller_test.rb new file mode 100644 index 000000000..fff5bd425 --- /dev/null +++ b/test/controllers/api/v1/auctions_controller_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::AuctionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @auction = auctions(:valid_with_offers) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + end + + test 'should show auction data for authorized user' do + get api_v1_auctions_url(auction_id: @auction.uuid), + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :success + json = JSON.parse(response.body) + assert json['offer'] + assert json['autobider'] + assert json['billing_profiles'] + end + + test 'should return unauthorized without token' do + get api_v1_auctions_url(auction_id: @auction.uuid), as: :json + assert_response :unauthorized + end +end \ No newline at end of file diff --git a/test/controllers/api/v1/autobiders_controller_test.rb b/test/controllers/api/v1/autobiders_controller_test.rb new file mode 100644 index 000000000..c2274cf71 --- /dev/null +++ b/test/controllers/api/v1/autobiders_controller_test.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::AutobidersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @second_user = users(:second_place_participant) + @english_auction = auctions(:english) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + @second_user_token = JWT.encode({ sub: @second_user.id }, @jwt_secret, 'HS256') + + # Enable mobile API for tests + Rails.application.config.customization[:mobile_api] = { enabled: true } + + @active_auction = Auction.new( + domain_name: 'active-test.test', + starts_at: Time.now.utc - 1.hour, + ends_at: Time.now.utc + 1.hour, + platform: 'english', + starting_price: 5.0, + min_bids_step: 5.0, + slipping_end: 5, + skip_validation: true + ) + @active_auction.save! + end + + test 'should return 403 when mobile API is disabled' do + Rails.application.config.customization[:mobile_api] = { enabled: false } + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @english_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :forbidden + json = JSON.parse(response.body) + assert_equal 'API is turned off', json['errors'] + end + + test 'should return unauthorized without token' do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @english_auction.domain_name, price: 100.0 } }, + as: :json + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal 'Unauthorized', json['errors'] + end + + test 'should return unauthorized with invalid token' do + invalid_token = JWT.encode({ sub: @user.id }, 'invalid_secret', 'HS256') + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @english_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{invalid_token}" }, + as: :json + + assert_response :unauthorized + end + + test 'should return unauthorized with malformed token' do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @english_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer invalid_token_format" }, + as: :json + + assert_response :unauthorized + end + + test 'should create new autobider successfully' do + assert_difference('Autobider.count', 1) do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :ok + json = JSON.parse(response.body) + assert_equal 'ok', json['status'] + + autobider = Autobider.last + assert_equal @user.id, autobider.user_id + assert_equal @active_auction.domain_name, autobider.domain_name + assert_equal 10000, autobider.cents # 100.0 EUR in cents + assert autobider.enable + end + + test 'should update existing autobider' do + existing_autobider = Autobider.create!( + user: @user, + domain_name: @active_auction.domain_name, + cents: 5000, + enable: false + ) + + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { + autobider: { + id: existing_autobider.id, + domain_name: @active_auction.domain_name, + price: 150.0 + } + }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :ok + json = JSON.parse(response.body) + assert_equal 'ok', json['status'] + + existing_autobider.reload + assert_equal 15000, existing_autobider.cents # 150.0 EUR in cents + assert existing_autobider.enable + end + + test 'should not update autobider belonging to different user' do + other_autobider = Autobider.create!( + user: @second_user, + domain_name: @active_auction.domain_name, + cents: 5000, + enable: false + ) + + assert_difference('Autobider.count', 1) do + post api_v1_autobiders_url, + params: { + autobider: { + id: other_autobider.id, + domain_name: @active_auction.domain_name, + price: 150.0 + } + }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :ok + other_autobider.reload + assert_equal 5000, other_autobider.cents # Unchanged + assert_not other_autobider.enable # Unchanged + end + + test 'should return error for invalid autobider data' do + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: -10.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert_equal 'error', json['status'] + end + + test 'should return error for zero price' do + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert_equal 'error', json['status'] + end + + test 'should call AutobiderService.autobid when creating autobider' do + spy_on_autobider_service = Spy.on(AutobiderService, :autobid) + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + assert spy_on_autobider_service.has_been_called? + end + + test 'should skip autobid when user is last bidder' do + # Create an offer from the same user first + Offer.create!( + auction: @active_auction, + user: @user, + cents: 5000, + billing_profile: billing_profiles(:private_person), + username: 'testuser' + ) + + spy_on_autobider_service = Spy.on(AutobiderService, :autobid) + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + assert_not spy_on_autobider_service.has_been_called? + end + + test 'should not skip autobid when different user is last bidder' do + second_user_profile = BillingProfile.create!( + user: @second_user, + name: 'Second User Profile', + alpha_two_country_code: 'EE', + city: 'Tallinn', + street: 'Test St', + postal_code: '12345' + ) + + Offer.create!( + auction: @active_auction, + user: @second_user, + cents: 5000, + billing_profile: second_user_profile, + username: 'otheruser' + ) + + spy_on_autobider_service = Spy.on(AutobiderService, :autobid) + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + assert spy_on_autobider_service.has_been_called? + end + + test 'should call broadcast service after successful creation' do + spy_on_broadcast_service = Spy.on(Auctions::UpdateListBroadcastService, :call) + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + assert spy_on_broadcast_service.has_been_called? + end + + test 'should not handle domain name that does not exist' do + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { autobider: { domain_name: 'nonexistent.test', price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert_equal 'error', json['status'] + assert_equal I18n.t('autobider.domain_name.does_not_exist'), json['message'] + end + + test 'should handle missing required parameters' do + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { autobider: { price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert_equal 'error', json['status'] + end + + test 'should handle empty parameters' do + post api_v1_autobiders_url, + params: {}, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :bad_request + end + + test 'should set autobider enabled to true for both new and existing' do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + autobider = Autobider.last + assert autobider.enable + + autobider.update!(enable: false) + + post api_v1_autobiders_url, + params: { + autobider: { + id: autobider.id, + domain_name: @active_auction.domain_name, + price: 200.0 + } + }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + autobider.reload + assert autobider.enable + end + + test 'should handle duplicate domain name for same user by updating' do + existing = Autobider.create!( + user: @user, + domain_name: @active_auction.domain_name, + cents: 5000, + enable: true + ) + + existed_autobider = Autobider.where(domain_name: @active_auction.domain_name).order(:created_at).last + + assert_no_difference('Autobider.count') do + post api_v1_autobiders_url, + params: { autobider: { id: existed_autobider.id, domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + end + + assert_response :ok + json = JSON.parse(response.body) + assert_equal 'ok', json['status'] + + existing.reload + assert_equal 10000, existing.cents + end + + test 'should find auction by domain name and call autobid service' do + spy_on_autobider_service = Spy.on(AutobiderService, :autobid) + spy_on_broadcast_service = Spy.on(Auctions::UpdateListBroadcastService, :call) + + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 100.0 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + assert spy_on_autobider_service.has_been_called? + assert spy_on_broadcast_service.has_been_called? + end + + test 'should handle decimal prices correctly' do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: 123.45 } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + + autobider = Autobider.last + assert_equal 12345, autobider.cents + end + + test 'should handle string prices correctly' do + post api_v1_autobiders_url, + params: { autobider: { domain_name: @active_auction.domain_name, price: '99.99' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + + autobider = Autobider.last + assert_equal 9999, autobider.cents + end + + test 'should only accept permitted parameters' do + post api_v1_autobiders_url, + params: { + autobider: { + domain_name: @active_auction.domain_name, + price: 100.0, + enable: false, + user_id: @second_user.id, + extra_param: 'ignored' + } + }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :ok + + autobider = Autobider.last + assert_equal @user.id, autobider.user_id + assert autobider.enable + end + +end \ No newline at end of file diff --git a/test/controllers/api/v1/billing_profiles_controller_test.rb b/test/controllers/api/v1/billing_profiles_controller_test.rb new file mode 100644 index 000000000..d5acd29a7 --- /dev/null +++ b/test/controllers/api/v1/billing_profiles_controller_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::BillingProfilesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + stub_billing_api_calls + end + + test 'should create billing profile' do + post api_v1_billing_profiles_url, + params: { billing_profile: { name: 'Test Billing Profile', vat_code: '1234567890', street: '123 Main St', city: 'Anytown', state: 'CA', postal_code: '12345', alpha_two_country_code: 'US', uuid: '123e4567-e89b-12d3-a456-426614174000' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :success + json = JSON.parse(response.body) + assert_equal 'Test Billing Profile', json['billing_profile']['name'] + assert_equal '1234567890', json['billing_profile']['vat_code'] + assert_equal '123 Main St', json['billing_profile']['street'] + assert_equal 'Anytown', json['billing_profile']['city'] + assert_equal 'CA', json['billing_profile']['state'] + assert_equal '12345', json['billing_profile']['postal_code'] + assert_equal 'US', json['billing_profile']['alpha_two_country_code'] + assert_equal '123e4567-e89b-12d3-a456-426614174000', json['billing_profile']['uuid'] + end + + test 'should update billing profile' do + put api_v1_billing_profile_url(@user.billing_profiles.first.id), + params: { billing_profile: { name: 'Updated Billing Profile' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :success + json = JSON.parse(response.body) + assert_equal 'Updated Billing Profile', json['billing_profile']['name'] + end + + test 'should not create billing profile with duplicate VAT code' do + vat_code = @user.billing_profiles.first.vat_code + post api_v1_billing_profiles_url, + params: { billing_profile: { name: 'Test Billing Profile', vat_code:, street: '123 Main St', city: 'Anytown', state: 'CA', postal_code: '12345', alpha_two_country_code: 'US', uuid: '123e4567-e89b-12d3-a456-426614174000' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert_equal 'Vat code is already taken', json['errors']['vat_code'][0] + end + + def stub_billing_api_calls + stub_request(:patch, 'http://eis_billing_system:3000/api/v1/invoice/update_invoice_data') + .to_return(status: 200, body: { invoice_number: '1234567890', transaction_amount: 100 }.to_json, headers: {}) + end +end \ No newline at end of file diff --git a/test/controllers/api/v1/invoices_controller_test.rb b/test/controllers/api/v1/invoices_controller_test.rb new file mode 100644 index 000000000..1b750c0a7 --- /dev/null +++ b/test/controllers/api/v1/invoices_controller_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::InvoicesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @deposit_auction = auctions(:deposit_english) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + + stub_billing_api_calls + end + + test 'should get invoices' do + get api_v1_invoices_url, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + all_invoices = @user.invoices + + assert_response :success + json = JSON.parse(response.body) + + assert_equal all_invoices.issued.count, json['issued_invoices'].count + assert_equal all_invoices.paid.count, json['paid_invoices'].count + assert_equal all_invoices.cancelled.with_ban.count, json['cancelled_payable_invoices'].count + assert_equal all_invoices.cancelled.without_ban.count, json['cancelled_expired_invoices'].count + assert_equal @user.domain_participate_auctions.count, json['deposit_paid'].count + end + + test 'should get one off payment' do + post api_v1_oneoff_payments_url(id: @user.invoices.first.uuid), + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :success + json = JSON.parse(response.body) + assert_equal 'http://oneoff.redirect', json['oneoff_redirect_link'] + end + + test 'should get pay deposit' do + post api_v1_pay_deposits_url(id: @deposit_auction.uuid), + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + assert_response :success + json = JSON.parse(response.body) + assert_equal 'http://oneoff.redirect', json['oneoff_redirect_link'] + end + + private + + def stub_billing_api_calls + message = { + oneoff_redirect_link: 'http://oneoff.redirect', + } + + stub_request(:post, 'http://eis_billing_system:3000/api/v1/invoice_generator/oneoff') + .to_return(status: 200, body: message.to_json, headers: {}) + + stub_request(:post, 'http://eis_billing_system:3000/api/v1/invoice_generator/deposit_prepayment') + .to_return(status: 200, body: message.to_json, headers: {}) + end +end \ No newline at end of file diff --git a/test/controllers/api/v1/offers_controller_test.rb b/test/controllers/api/v1/offers_controller_test.rb new file mode 100644 index 000000000..aa5b10de9 --- /dev/null +++ b/test/controllers/api/v1/offers_controller_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::OffersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + travel_to Time.parse('2010-07-05 10:30 +0000').in_time_zone + end + + def test_show_offers + offers = Offer.includes(:auction) + .includes(:result) + .where(user_id: @user) + .order('auctions.ends_at DESC') + offers = offers.as_json( + include: %i[auction billing_profile], + methods: %i[auction_status api_price api_total api_bidders] + ) + + get api_v1_offers_url, headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, as: :json + assert_response :success + assert_equal offers, JSON.parse(response.body) + end + + def test_create_offer + auction = auctions(:valid_with_offers) + + assert_equal 50.00, auction.highest_price.to_f + post api_v1_offers_url, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + params: { bid: { auction_id: auction.uuid, price: 60 } }, + as: :json + + assert_response :success + assert_equal 60.00, auction.highest_price.to_f + end + + def test_update_offer_for_english_auction + auction = auctions(:english) + offer = auction.offers.first + post api_v1_offers_url, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + params: { bid: { auction_id: auction.uuid, price: 60, billing_profile_id: @user.billing_profiles.first.id } }, + as: :json + + auction.reload + + assert_response :success + assert_equal 60.00, auction.highest_price.to_f + assert_equal 61.00, auction.min_bids_step.to_f + end +end \ No newline at end of file diff --git a/test/controllers/api/v1/passwords_controller_test.rb b/test/controllers/api/v1/passwords_controller_test.rb new file mode 100644 index 000000000..259a2c7bf --- /dev/null +++ b/test/controllers/api/v1/passwords_controller_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::PasswordsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + travel_to Time.parse('2010-07-05 10:30 +0000').in_time_zone + end + + def test_update_password + assert @user.valid_password?('password123') + + put api_v1_profiles_passwords_url, + params: { user: { password: 'newpassword', password_confirmation: 'newpassword', current_password: 'password123' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + @user.reload + + assert_response :success + assert @user.valid_password?('newpassword') + end + + def test_update_password_with_invalid_current_password + assert @user.valid_password?('password123') + + put api_v1_profiles_passwords_url, + params: { user: { password: 'newpassword', password_confirmation: 'newpassword', current_password: 'invalid' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + @user.reload + + assert_response :unprocessable_entity + assert @user.valid_password?('password123') + end +end \ No newline at end of file diff --git a/test/controllers/api/v1/profiles_controller_test.rb b/test/controllers/api/v1/profiles_controller_test.rb new file mode 100644 index 000000000..c29743e18 --- /dev/null +++ b/test/controllers/api/v1/profiles_controller_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'jwt' + +class Api::V1::ProfilesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:participant) + @jwt_secret = Rails.application.config.customization[:jwt_secret] || 'jwt_secret' + @token = JWT.encode({ sub: @user.id }, @jwt_secret, 'HS256') + travel_to Time.parse('2010-07-05 10:30 +0000').in_time_zone + stub_billing_api_calls + end + + def test_create_new_user_profile + assert_difference 'User.count' do + post api_v1_profiles_url, + params: { user: { email: 'test@example.com', password: 'password', password_confirmation: 'password', country_code: 'EE', given_names: 'John', surname: 'Doe', mobile_phone: '+1234567890', accepts_terms_and_conditions: true, locale: 'en' } }, + as: :json + + assert_response :success + end + end + + def test_update_user_profile + assert_equal @user.given_names, 'Joe John' + assert_equal @user.surname, 'Participant' + + put api_v1_profiles_url, + params: { user: { email: 'test@example.com', password: 'password', password_confirmation: 'password', country_code: 'EE', given_names: 'John', surname: 'Doe', mobile_phone: '+1234567890', accepts_terms_and_conditions: true, locale: 'en' } }, + headers: { 'HTTP_AUTHORIZATION' => "Bearer #{@token}" }, + as: :json + + @user.reload + + assert_response :success + assert_equal @user.given_names, 'John' + assert_equal @user.surname, 'Doe' + end + + private + + def stub_billing_api_calls + stub_request(:post, 'http://eis_billing_system:3000/api/v1/invoice_generator/reference_number_generator') + .to_return(status: 200, body: { reference_number: '1234567890' }.to_json, headers: {}) + end +end