app/services/data_sources/json_user_data_source.rb

Summary

Maintainability
A
0 mins
Test Coverage
require "bcrypt"
require "securerandom"
require_relative "json_data_source"
require_relative "user_data_source"
require_relative "../../shared/logging_module"
require_relative "../../shared/json_convertible"
require_relative "../../shared/models/user"
require_relative "../../shared/models/provider_credential"
require_relative "../../shared/models/github_provider_credential"

module FastlaneCI
  # Mixin the JSONConvertible class for User
  class User
    include FastlaneCI::JSONConvertible

    def self.attribute_to_type_map
      return { :@provider_credentials => GitHubProviderCredential }
    end

    def self.map_enumerable_type(enumerable_property_name: nil, current_json_object: nil)
      if enumerable_property_name == :@provider_credentials
        type = current_json_object["type"]
        # currently only supports 1 type, but we could automate this part too
        provider_credential = nil
        if type == FastlaneCI::ProviderCredential::PROVIDER_CREDENTIAL_TYPES[:github]
          provider_credential = GitHubProviderCredential.from_json!(current_json_object)
        end
        provider_credential
      end
    end
  end

  # Mixin the JSONConvertible class for all Providers
  class ProviderCredential
    include FastlaneCI::JSONConvertible
  end

  # Data source for users backed by JSON
  class JSONUserDataSource < UserDataSource
    include FastlaneCI::JSONDataSource
    include FastlaneCI::Logging

    class << self
      attr_accessor :file_semaphore
    end

    # can't have us reading and writing to a file at the same time
    JSONUserDataSource.file_semaphore = Mutex.new

    def after_creation(**params)
      logger.debug("Using folder path for user data: #{json_folder_path}")
      # load up the json file here
      # parse all data into objects so we can fail fast on error
      reload_users
    end

    def user_file_path(path: "users.json")
      File.join(json_folder_path, path)
    end

    def users
      JSONUserDataSource.file_semaphore.synchronize do
        return @users
      end
    end

    def users=(users)
      JSONUserDataSource.file_semaphore.synchronize do
        @users.each do |user|
          # Fist need to serialize the provider credentials and ignore the `ci_user`
          # instance variable. The reasoning is since if you serialize the `user`
          # first, you will call `to_object_map` on the `ci_user`, which holds
          # reference to a user. This will go on indefinitely
          user.provider_credentials.map! do |credential|
            credential.to_object_dictionary(ignore_instance_variables: [:@ci_user])
          end
        end

        File.write(user_file_path, JSON.pretty_generate(users.map(&:to_object_dictionary)))
      end

      # Reload the users to sync them up with the persisted file store
      reload_users
    end

    def reload_users
      JSONUserDataSource.file_semaphore.synchronize do
        unless File.exist?(user_file_path)
          @users = []
          return
        end

        @users = JSON.parse(File.read(user_file_path)).map do |user_object_hash|
          user = User.from_json!(user_object_hash)
          user.provider_credentials.each do |provider_credential|
            # Provide the back-reference
            provider_credential.ci_user = user
          end
          user
        end
      end
    end

    def login(email:, password:)
      user = users.detect { |existing_user| existing_user.email.casecmp(email.downcase).zero? }

      if user.nil?
        logger.debug("Couldn't find user with email #{email} in list of available accounts")
        # user doesn't exist
        return nil
      end

      # cool, the user exists, but we need to check the password
      user_password = BCrypt::Password.new(user.password_hash)

      # sweet, it matches, return the user
      if user_password == password
        logger.debug("User #{email} authenticated")
        return user
      end

      # nope, wrong password
      logger.debug("User #{email} authentication failed")
      return nil
    end

    # just check to see if we have a user with that email...
    def user_exist?(email:)
      return users.any? { |existing_user| existing_user.email.casecmp(email.downcase).zero? }
    end

    # TODO: this isn't threadsafe
    def update_user!(user:)
      user_index = user_index(user: user)

      if user_index.nil?
        logger.debug("Couldn't update user #{user.email} because they don't exist")
        raise "Couldn't update user #{user.email} because they don't exist"
      else
        users[user_index] = user
        logger.debug("Updating user #{user.email}, writing out users.json to #{user_file_path}")
      end
    end

    def delete_user!(user:)
      if find_user(id: user.id).nil?
        logger.debug("Couldn't delete user #{user.email} because they don't exist")
        raise "Couldn't delete user #{user.email} because they don't exist"
      else
        users.delete(user)
        logger.debug("Deleted user #{user.email}, writing out users.json to #{user_file_path}")
      end
    end

    def create_user!(id: nil, email:, password:, provider_credentials: [])
      users = self.users
      new_user = User.new(
        id: id,
        email: email,
        password_hash: BCrypt::Password.create(password),
        provider_credentials: provider_credentials
      )

      if !user_exist?(email: email)
        users.push(new_user)
        self.users = users
        logger.debug("Added user #{new_user.email}, writing out users.json to #{user_file_path}")
        return new_user
      else
        logger.debug("Couldn't add user #{new_user.email} because they already exist")
        return nil
      end
    end

    # Finds a user with a given `id`
    #
    # @param  [String] `id` the UUID for a user to find.
    # @return [User]
    def find_user(id:)
      return users.detect { |user| user.id == id }
    end

    private

    # Finds the index of the user, if it exists
    #
    # @param  [User] `user` a user to lookup by `user.id`
    # @return [Integer] `user_index` in the `users` array
    def user_index(user:)
      users.each.with_index { |old_user, index| return index if old_user.id == user.id }
      return nil
    end
  end
end