diff --git a/.gitignore b/.gitignore index a1745045c68682b9de220f8c072c09246638e18f..2fe073745dc8847cc58b08e7f2d2199b5840d42c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ /coverage/ /doc/ /pkg/ -/spec/reports/ +/test/reports/ /tmp/ /vendor/ Gemfile.lock +*.gem diff --git a/.rubocop.yml b/.rubocop.yml index 774957aa99f2b7ded40c72d53d8d676d4cb92a67..aea3ae705d800a6dc1738ebb2a036f73845be528 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,10 +1,11 @@ --- AllCops: - TargetRubyVersion: 2.4 + TargetRubyVersion: 2.7 Exclude: - '*.spec' - 'Rakefile' - 'vendor/**/*' + NewCops: enable # Don't enforce documentation Style/Documentation: diff --git a/Rakefile b/Rakefile index d433a1edc1b09f60e4966db4839d9c57b2828d98..fe277be815c5651db25cf75d8d5933d76487a32a 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,5 @@ -require "bundler/gem_tasks" -require "rake/testtask" +require 'bundler/gem_tasks' +require 'rake/testtask' Rake::TestTask.new(:test) do |t| t.libs << "test" diff --git a/lib/passwordstate.rb b/lib/passwordstate.rb index 49a57d10e6f7472a8b84798788ce5c32c01a3e32..d9e0ea224a1693f595b6221fdaece4a2e66ae036 100644 --- a/lib/passwordstate.rb +++ b/lib/passwordstate.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require 'logging' +require 'pp' require 'passwordstate/version' require 'passwordstate/client' require 'passwordstate/errors' diff --git a/lib/passwordstate/client.rb b/lib/passwordstate/client.rb index 034044e391ce32127ad664c8bef90919e8e65bce..ac4fe045f388adad04bd120d665b5d61b05c4f14 100644 --- a/lib/passwordstate/client.rb +++ b/lib/passwordstate/client.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require 'json' module Passwordstate class Client - USER_AGENT = "RubyPasswordstate/#{Passwordstate::VERSION}".freeze + USER_AGENT = "RubyPasswordstate/#{Passwordstate::VERSION}" DEFAULT_HEADERS = { 'accept' => 'application/json', 'user-agent' => USER_AGENT @@ -34,6 +36,11 @@ module Passwordstate @http.read_timeout = sec if @http end + def address_book + ResourceList.new Passwordstate::Resources::AddressBook, + client: self + end + def folders ResourceList.new Passwordstate::Resources::Folder, client: self, @@ -69,15 +76,25 @@ module Passwordstate html = request(:get, '', allow_html: true) version = html.find_line { |line| line.include? '<span>V</span>' } version = />(\d\.\d) \(Build (.+)\)</.match(version) - "#{version[1]}.#{version[2]}" + "#{version[1]}.#{version[2]}" if version end end def version?(compare) + if version.nil? + logger.debug 'Unable to detect Passwordstate version, assuming recent enough.' + return true + end + Gem::Dependency.new(to_s, compare).match?(to_s, version) end def require_version(compare) + if version.nil? + logger.debug 'Unable to detect Passwordstate version, assuming recent enough.' + return true + end + raise "Your version of Passwordstate (#{version}) doesn't support the requested feature" unless version? compare end @@ -108,9 +125,9 @@ module Passwordstate if data return data if res_obj.is_a? Net::HTTPSuccess - data = data.first if data.is_a? Array - parsed = data.fetch('errors', []) if data.is_a?(Hash) && data.key?('errors') - parsed = [data] + # data = data.first if data.is_a? Array + # parsed = data.fetch('errors', []) if data.is_a?(Hash) && data.key?('errors') + parsed = [data].flatten raise Passwordstate::HTTPError.new_by_code(res_obj.code, req_obj, res_obj, parsed || []) else @@ -120,10 +137,16 @@ module Passwordstate end end - def inspect - "#{to_s[0..-2]} #{instance_variables.reject { |k| %i[@auth_data @http @logger].include? k }.map { |k| "#{k}=#{instance_variable_get(k).inspect}" }.join ', '}>" + def pretty_print_instance_variables + instance_variables.reject { |k| %i[@auth_data @http @logger].include? k }.sort end + def pretty_print(pp) + pp.pp(self) + end + + alias inspect pretty_print_inspect + private def http @@ -136,7 +159,7 @@ module Passwordstate @http.start end - def print_http(http, truncate = true) + def print_http(http, truncate: true) return unless logger.debug? if http.is_a? Net::HTTPRequest diff --git a/lib/passwordstate/errors.rb b/lib/passwordstate/errors.rb index 15debd711e739c428a27a88d648324581fd6944e..b8719f4269f4bdc8b7170ecce42a4e89d3220087 100644 --- a/lib/passwordstate/errors.rb +++ b/lib/passwordstate/errors.rb @@ -1,6 +1,10 @@ +# frozen_string_literal: true + module Passwordstate class PasswordstateError < RuntimeError; end + class NotAcceptableError < PasswordstateError; end + class HTTPError < PasswordstateError attr_reader :code, :request, :response, :errors diff --git a/lib/passwordstate/resource.rb b/lib/passwordstate/resource.rb index 76bffa8cc8aff4a83580be3794b0119a0c8342e2..28b93b8b77c56e44ca71f7d9f9236c924cc12462 100644 --- a/lib/passwordstate/resource.rb +++ b/lib/passwordstate/resource.rb @@ -1,28 +1,30 @@ +# frozen_string_literal: true + module Passwordstate # A simple resource DSL class Resource attr_reader :client - def get(query = {}) - set! self.class.get(client, send(self.class.index_field), query) + def get(**query) + set! self.class.get(client, send(self.class.index_field), **query) end - def put(body = {}, query = {}) + def put(body = {}, **query) to_send = modified.merge(self.class.index_field => send(self.class.index_field)) - set! self.class.put(client, to_send.merge(body), query).first + set! self.class.put(client, to_send.merge(body), **query).first end - def post(body = {}, query = {}) - set! self.class.post(client, attributes.merge(body), query) + def post(body = {}, **query) + set! self.class.post(client, attributes.merge(body), **query) end - def delete(query = {}) - self.class.delete(client, send(self.class.index_field), query) + def delete(**query) + self.class.delete(client, send(self.class.index_field), **query) end def initialize(data) @client = data.delete :_client - set! data, false + set! data, store_old: false old end @@ -34,7 +36,9 @@ module Passwordstate true end - def self.all(client, query = {}) + def self.all(client, **query) + raise NotAcceptableError, "Read is not implemented for #{self}" unless acceptable_methods.include? :get + path = query.fetch(:_api_path, api_path) reason = query.delete(:_reason) query = passwordstateify_hash query.reject { |k| k.to_s.start_with? '_' } @@ -44,7 +48,9 @@ module Passwordstate end end - def self.get(client, object, query = {}) + def self.get(client, object, **query) + raise NotAcceptableError, "Read is not implemented for #{self}" unless acceptable_methods.include? :get + object = object.send(object.class.send(index_field)) if object.is_a? Resource return new _client: client, index_field => object if query[:_bare] @@ -61,7 +67,9 @@ module Passwordstate resp end - def self.post(client, data, query = {}) + def self.post(client, data, **query) + raise NotAcceptableError, "Create is not implemented for #{self}" unless acceptable_methods.include? :post + path = query.fetch(:_api_path, api_path) reason = query.delete(:_reason) data = passwordstateify_hash data @@ -70,7 +78,9 @@ module Passwordstate new [client.request(:post, path, body: data, query: query, reason: reason)].flatten.first.merge(_client: client) end - def self.put(client, data, query = {}) + def self.put(client, data, **query) + raise NotAcceptableError, "Update is not implemented for #{self}" unless acceptable_methods.include? :put + path = query.fetch(:_api_path, api_path) reason = query.delete(:_reason) data = passwordstateify_hash data @@ -79,7 +89,9 @@ module Passwordstate client.request :put, path, body: data, query: query, reason: reason end - def self.delete(client, object, query = {}) + def self.delete(client, object, **query) + raise NotAcceptableError, "Delete is not implemented for #{self}" unless acceptable_methods.include? :delete + path = query.fetch(:_api_path, api_path) reason = query.delete(:_reason) query = passwordstateify_hash query.reject { |k| k.to_s.start_with? '_' } @@ -89,27 +101,42 @@ module Passwordstate end def self.passwordstateify_hash(hash) - Hash[hash.map { |k, v| [ruby_to_passwordstate_field(k), v] }] + hash.transform_keys { |k| ruby_to_passwordstate_field(k) } end def api_path self.class.instance_variable_get :@api_path end - def attributes(opts = {}) + def attributes(**opts) ignore_redact = opts.fetch(:ignore_redact, true) + atify = opts.fetch(:atify, false) nil_as_string = opts.fetch(:nil_as_string, self.class.nil_as_string) - Hash[(self.class.send(:accessor_field_names) + self.class.send(:read_field_names) + self.class.send(:write_field_names)).map do |field| + (self.class.send(:accessor_field_names) + self.class.send(:read_field_names) + self.class.send(:write_field_names)).to_h do |field| redact = self.class.send(:field_options)[field]&.fetch(:redact, false) && !ignore_redact - value = instance_variable_get("@#{field}".to_sym) unless redact + at_field = "@#{field}".to_sym + field = at_field if atify + value = instance_variable_get(at_field) unless redact value = '[ REDACTED ]' if redact value = '' if value.nil? && nil_as_string [field, value] - end].reject { |_k, v| v.nil? } + end.compact end - def inspect - "#{to_s[0..-2]} #{attributes(nil_as_string: false, ignore_redact: false).reject { |_k, v| v.nil? }.map { |k, v| "@#{k}=#{v.inspect}" }.join(', ')}>" + def pretty_print(pp) + pp.object_address_group(self) do + attrs = attributes(atify: true, nil_as_string: false, ignore_redact: false).compact + pp.seplist(attrs.keys.sort, -> { pp.text ',' }) do |v| + pp.breakable + v = v.to_s if v.is_a? Symbol + pp.text v + pp.text '=' + pp.group(1) do + pp.breakable '' + pp.pp(attrs[v.to_sym]) + end + end + end end protected @@ -127,7 +154,7 @@ module Passwordstate @old ||= attributes.dup end - def set!(data, store_old = true) + def set!(data, store_old: true) @old = attributes.dup if store_old data = data.attributes if data.is_a? Passwordstate::Resource data.each do |key, value| @@ -138,8 +165,11 @@ module Passwordstate if !value.nil? && opts&.key?(:is) klass = opts.fetch(:is) - parsed_value = klass.send :parse, value rescue nil if klass.respond_to? :parse - parsed_value ||= klass.send :new, value rescue nil if klass.respond_to? :new + parsed_value = klass.send :parse, value rescue nil if klass.respond_to?(:parse) + parsed_value ||= klass.send :new, value rescue nil if klass.respond_to?(:new) && !klass.respond_to?(:parse) + elsif !value.nil? && opts&.key?(:convert) + convert = opts.fetch(:convert) + parsed_value = convert.call(value, direction: :from) end instance_variable_set "@#{field}".to_sym, parsed_value || value @@ -165,6 +195,21 @@ module Passwordstate @nil_as_string end + def acceptable_methods(*meths) + if meths.empty? + @acceptable_methods || %i[post get put delete] + elsif meths.count == 1 && meths.to_s.upcase == meths.to_s + @acceptable_methods = [] + meths = meths.first.to_s + @acceptable_methods << :post if meths.include? 'C' + @acceptable_methods << :get if meths.include? 'R' + @acceptable_methods << :put if meths.include? 'U' + @acceptable_methods << :delete if meths.include? 'D' + else + @acceptable_methods = meths + end + end + def passwordstate_to_ruby_field(field) opts = send(:field_options).find { |(_k, v)| v[:name] == field } opts&.first || field.to_s.snake_case.to_sym @@ -232,16 +277,20 @@ module Passwordstate end module Resources - autoload :Document, 'passwordstate/resources/document' - autoload :Folder, 'passwordstate/resources/folder' - autoload :FolderPermission, 'passwordstate/resources/folder' - autoload :Host, 'passwordstate/resources/host' - autoload :PasswordList, 'passwordstate/resources/password_list' - autoload :PasswordListPermission, 'passwordstate/resources/password_list' - autoload :Password, 'passwordstate/resources/password' - autoload :PasswordHistory, 'passwordstate/resources/password' - autoload :PasswordPermission, 'passwordstate/resources/password_list' - autoload :Permission, 'passwordstate/resources/permission' - autoload :Report, 'passwordstate/resources/report' + autoload :ActiveDirectory, 'passwordstate/resources/active_directory' + autoload :AddressBook, 'passwordstate/resources/address_book' + autoload :Document, 'passwordstate/resources/document' + autoload :Folder, 'passwordstate/resources/folder' + autoload :FolderPermission, 'passwordstate/resources/folder' + autoload :Host, 'passwordstate/resources/host' + autoload :Password, 'passwordstate/resources/password' + autoload :PasswordList, 'passwordstate/resources/password_list' + autoload :PasswordListPermission, 'passwordstate/resources/password_list' + autoload :PasswordHistory, 'passwordstate/resources/password' + autoload :PasswordPermission, 'passwordstate/resources/password_list' + autoload :PrivilegedAccount, 'passwordstate/resources/privileged_account' + autoload :PrivilegedAccountPermission, 'passwordstate/resources/privileged_account' + autoload :Permission, 'passwordstate/resources/permission' + autoload :Report, 'passwordstate/resources/report' end end diff --git a/lib/passwordstate/resource_list.rb b/lib/passwordstate/resource_list.rb index 2ce77e34ffe9326a5171f0b78f16e51518af9126..e5c4650555c8af9c7d5ff4a8fd0780f7d95b977b 100644 --- a/lib/passwordstate/resource_list.rb +++ b/lib/passwordstate/resource_list.rb @@ -1,30 +1,8 @@ -module Passwordstate - class ResourceList < Array - Array.public_instance_methods(false).each do |method| - next if %i[reject select slice clear inspect].include?(method.to_sym) - - class_eval <<-EVAL, __FILE__, __LINE__ + 1 - def #{method}(*args) - lazy_load unless @loaded - super - end - EVAL - end - - %w[reject select slice].each do |method| - class_eval <<-EVAL, __FILE__, __LINE__ + 1 - def #{method}(*args) - lazy_load unless @loaded - data = super - self.clone.clear.concat(data) - end - EVAL - end +# frozen_string_literal: true - def inspect - lazy_load unless @loaded - super - end +module Passwordstate + class ResourceList + include Enumerable attr_reader :client, :resource, :options @@ -33,13 +11,37 @@ module Passwordstate @resource = resource @loaded = false @options = options + @data = [] options[:only] = [options[:only]].flatten if options.key? :only options[:except] = [options[:except]].flatten if options.key? :except end + def pretty_print_instance_variables + instance_variables.reject { |k| %i[@client @data].include? k }.sort + end + + def pretty_print(pp) + pp.pp_object(self) + end + + alias inspect pretty_print_inspect + + def each(&block) + lazy_load unless @loaded + + return to_enum(__method__) { @data.size } unless block_given? + + @data.each(&block) + end + + def [](index) + @data[index] + end + def clear - @loaded = super + @data = [] + @loaded = false end def reload @@ -48,9 +50,12 @@ module Passwordstate end def load(entries) - clear && entries.tap do |loaded| + clear + entries.tap do |loaded| loaded.sort! { |a, b| a.send(a.class.index_field) <=> b.send(b.class.index_field) } if options.fetch(:sort, true) - end.each { |obj| self << obj } + end + entries.each { |obj| @data << obj } + @loaded = true self end @@ -74,29 +79,29 @@ module Passwordstate obj end - def search(query = {}) + def search(**query) raise 'Operation not supported' unless operation_supported?(:search) api_path = options.fetch(:search_path, resource.api_path) query = options.fetch(:search_query, {}).merge(query) - resource.search(client, query.merge(_api_path: api_path)) + resource.search(client, **query.merge(_api_path: api_path)) end - def all(query = {}) + def all(**query) raise 'Operation not supported' unless operation_supported?(:all) api_path = options.fetch(:all_path, resource.api_path) query = options.fetch(:all_query, {}).merge(query) - load resource.all(client, query.merge(_api_path: api_path)) + load resource.all(client, **query.merge(_api_path: api_path)) end - def get(id, query = {}) + def get(id, **query) raise 'Operation not supported' unless operation_supported?(:get) - if query.empty? && !entries.empty? - existing = entries.find do |entry| + if query.empty? && !@data.empty? + existing = @data.find do |entry| entry.send(entry.class.index_field) == id end return existing if existing @@ -105,34 +110,34 @@ module Passwordstate api_path = options.fetch(:get_path, resource.api_path) query = options.fetch(:get_query, {}).merge(query) - resource.get(client, id, query.merge(_api_path: api_path)) + resource.get(client, id, **query.merge(_api_path: api_path)) end - def post(data, query = {}) + def post(data, **query) raise 'Operation not supported' unless operation_supported?(:post) api_path = options.fetch(:post_path, resource.api_path) query = options.fetch(:post_query, {}).merge(query) - resource.post(client, data, query.merge(_api_path: api_path)) + resource.post(client, data, **query.merge(_api_path: api_path)) end - def put(data, query = {}) + def put(data, **query) raise 'Operation not supported' unless operation_supported?(:put) api_path = options.fetch(:put_path, resource.api_path) query = options.fetch(:put_query, {}).merge(query) - resource.put(client, data, query.merge(_api_path: api_path)) + resource.put(client, data, **query.merge(_api_path: api_path)) end - def delete(id, query = {}) + def delete(id, **query) raise 'Operation not supported' unless operation_supported?(:delete) api_path = options.fetch(:delete_path, resource.api_path) query = options.fetch(:delete_query, {}).merge(query) - resource.delete(client, id, query.merge(_api_path: api_path)) + resource.delete(client, id, **query.merge(_api_path: api_path)) end private diff --git a/lib/passwordstate/resources/active_directory.rb b/lib/passwordstate/resources/active_directory.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4353b0a06997021175544d727c5bdf826445957 --- /dev/null +++ b/lib/passwordstate/resources/active_directory.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Passwordstate + module Resources + class ActiveDirectory < Passwordstate::Resource + api_path 'activedirectory' + + index_field :ad_domain_id + + accessor_fields :ad_domain_netbios, { name: 'ADDomainNetBIOS' }, + :ad_domain_ldap, { name: 'ADDomainLDAP' }, + :fqdn, { name: 'FQDN' }, + :default_domain, + :pa_read_id, { name: 'PAReadID' }, + :site_id, { name: 'SiteID' }, + :used_for_authentication, + :protocol, + :domain_controller_fqdn, { name: 'DomainControllerFQDN' } + + read_fields :ad_domain_id, { name: 'ADDomainID' } + end + end +end diff --git a/lib/passwordstate/resources/address_book.rb b/lib/passwordstate/resources/address_book.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc24658e5551dcb9849231a22e99cf71560b5a46 --- /dev/null +++ b/lib/passwordstate/resources/address_book.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Passwordstate + module Resources + class AddressBook < Passwordstate::Resource + api_path 'addressbook' + + index_field :address_book_id + + accessor_fields :first_name, + :surname, + :email_adress, + :company, + :business_phone, + :personal_phone, + :street, + :city, + :state, + :zipcode, + :country, + :notes, + :pass_phrase, + :global_contact + + read_fields :address_book_id, { name: 'AddressBookID' } + end + end +end diff --git a/lib/passwordstate/resources/document.rb b/lib/passwordstate/resources/document.rb index c700888e081f184c437dfe4ebda0ec1e79716473..6287a633649f098846b1e8c5de2f3f5a6b53c6e1 100644 --- a/lib/passwordstate/resources/document.rb +++ b/lib/passwordstate/resources/document.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate module Resources class Document < Passwordstate::Resource @@ -10,12 +12,38 @@ module Passwordstate alias title document_name - def self.search(client, store, options = {}) - client.request :get, "#{api_path}/#{store}/", query: options + def self.all(client, store, **query) + super client, query.merge(_api_path: "#{api_path}/#{validate_store! store}") + end + + def self.search(client, store, **options) + all client, store, **options end def self.get(client, store, object) - client.request :get, "#{api_path}/#{store}/#{object}" + super client, object, _api_path: "#{api_path}/#{validate_store! store}" + end + + def self.post(client, store, data, **query) + super client, data, query.merge(_api_path: "#{api_path}/#{validate_store! store}") + end + + def self.put(client, store, data, **query) + super client, data, query.merge(_api_path: "#{api_path}/#{validate_store! store}") + end + + def self.delete(client, store, object, **query) + super client, object, query.merge(_api_path: "#{api_path}/#{validate_store! store}") + end + + class << self + private + + def validate_store!(store) + raise ArgumentError, 'Store must be one of password, passwordlist, folder' unless %i[password passwordlist folder].include?(store.to_s.downcase.to_sym) + + store.to_s.downcase.to_sym + end end end end diff --git a/lib/passwordstate/resources/folder.rb b/lib/passwordstate/resources/folder.rb index 1fe3c0106f8ab10bf96960baa10b9c995f318c00..9d391906057f38be683dc113419c5318c5d4b778 100644 --- a/lib/passwordstate/resources/folder.rb +++ b/lib/passwordstate/resources/folder.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate module Resources class Folder < Passwordstate::Resource @@ -29,7 +31,7 @@ module Passwordstate FolderPermission.new(_client: client, folder_id: folder_id) end - def full_path(unix = false) + def full_path(unix: false) return tree_path.tr('\\', '/') if unix tree_path @@ -41,7 +43,7 @@ module Passwordstate index_field :folder_id - read_fields :folder_id, { name: 'FolderID' } # rubocop:disable Style/BracesAroundHashParameters + read_fields :folder_id, { name: 'FolderID' } end end end diff --git a/lib/passwordstate/resources/host.rb b/lib/passwordstate/resources/host.rb index 2fc71e0664cd88214728f38a4d09c95e006f1890..f3c1c5af7435dc7452c4808d947c605459e28d65 100644 --- a/lib/passwordstate/resources/host.rb +++ b/lib/passwordstate/resources/host.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'ipaddr' module Passwordstate diff --git a/lib/passwordstate/resources/password.rb b/lib/passwordstate/resources/password.rb index 5c51cd0ad6eca87e2b7256e181aa2e044ea9b0e8..69b88fdbd69417631a2ba5532a3717f6b35f95ef 100644 --- a/lib/passwordstate/resources/password.rb +++ b/lib/passwordstate/resources/password.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate module Resources class Password < Passwordstate::Resource @@ -5,6 +7,7 @@ module Passwordstate index_field :password_id + # rubocop:disable Naming/VariableNumber accessor_fields :title, :domain, :host_name, @@ -20,6 +23,7 @@ module Passwordstate :generic_field_8, { name: 'GenericField8' }, :generic_field_9, { name: 'GenericField9' }, :generic_field_10, { name: 'GenericField10' }, + :generic_field_info, :account_type_id, { name: 'AccountTypeID' }, :notes, :url, @@ -28,11 +32,17 @@ module Passwordstate :allow_export, :web_user_id, { name: 'WebUser_ID' }, :web_password_id, { name: 'WebPassword_ID' }, - :password_list_id, { name: 'PasswordListID' } # Note: POST only # rubocop:disable Style/BracesAroundHashParameters + :password_list_id, { name: 'PasswordListID' } # NB: POST only + # rubocop:enable Naming/VariableNumber read_fields :account_type, :password_id, { name: 'PasswordID' }, - :password_list + :password_list, + :otp, + # For 'Managed' passwords + :status, + :current_password, + :new_password # Things that can be set in a POST/PUT request # TODO: Do this properly @@ -47,12 +57,34 @@ module Passwordstate :heartbeat_enabled, :heartbeat_schedule, :validation_script_id, { name: 'ValidationScriptID' }, - :host_name, :ad_domain_netbios, { name: 'ADDomainNetBIOS' }, :validate_with_priv_account + def otp! + client.request(:get, "onetimepassword/#{password_id}").first['OTP'] + end + def check_in - client.request :get, "passwords/#{password_id}", query: passwordstatify_hash(check_in: nil) + client.request :get, "passwords/#{password_id}", query: self.class.passwordstateify_hash(check_in: nil) + end + + def send_selfdestruct(email, expires_at:, view_count:, reason: nil, **params) + data = { + password_id: password_id, + to_email_address: email, + expires_at: expires_at, + no_views: view_count + } + data[:message] = params[:message] if params.key? :message + data[:prefix_message_content] = params[:prefix_message] if params.key? :prefix_message + data[:append_message_content] = params[:suffix_message] if params.key? :suffix_message + data[:to_first_name] = params[:name] if params.key? :name + data[:email_subject] = params[:subject] if params.key? :subject + data[:email_body] = params[:body] if params.key? :body + data[:passphrase] = params[:passphrase] if params.key? :passphrase + data[:reason] = reason if reason + + client.request :post, 'selfdestruct', data: data end def history @@ -69,25 +101,25 @@ module Passwordstate PasswordPermission.new(_client: client, password_id: password_id) end - def delete(recycle = false, query = {}) - super query.merge(move_to_recycle_bin: recycle) + def delete(recycle: false, **query) + super(**query.merge(move_to_recycle_bin: recycle)) end - def add_dependency(data = {}) + def add_dependency(**data) raise 'Password dependency creation only available for stored passwords' unless stored? client.request :post, 'dependencies', body: self.class.passwordstatify_hash(data.merge(password_id: password_id)) end - def self.all(client, query = {}) - super client, { query_all: true }.merge(query) + def self.all(client, **query) + super client, **{ query_all: true }.merge(query) end - def self.search(client, query = {}) - super client, { _api_path: 'searchpasswords' }.merge(query) + def self.search(client, **query) + super client, **{ _api_path: 'searchpasswords' }.merge(query) end - def self.generate(client, options = {}) + def self.generate(client, **options) results = client.request(:get, 'generatepassword', query: options).map { |r| r['Password'] } return results.first if results.count == 1 @@ -110,6 +142,7 @@ module Passwordstate :surname # Password object fields + # rubocop:disable Naming/VariableNumber read_fields :title, :domain, :host_name, @@ -134,7 +167,8 @@ module Passwordstate :expiry_date, { is: Time }, :allow_export, :web_user_id, { name: 'WebUser_ID' }, - :web_password_id, { name: 'WebPassword_ID' } # rubocop:disable Style/BracesAroundHashParameters + :web_password_id, { name: 'WebPassword_ID' } + # rubocop:enable Naming/VariableNumber def get raise 'Not applicable' @@ -146,7 +180,7 @@ module Passwordstate index_field :password_id - read_fields :password_id, { name: 'PasswordID' } # rubocop:disable Style/BracesAroundHashParameters + read_fields :password_id, { name: 'PasswordID' } end end end diff --git a/lib/passwordstate/resources/password_list.rb b/lib/passwordstate/resources/password_list.rb index 9c880f9953bf7a9da1ae284f84efc48f3df20f08..39f0e9b6dc53324e447117c92ce2278eeac7ec8a 100644 --- a/lib/passwordstate/resources/password_list.rb +++ b/lib/passwordstate/resources/password_list.rb @@ -1,7 +1,22 @@ +# frozen_string_literal: true + module Passwordstate module Resources class PasswordList < Passwordstate::Resource + HideConfig = Struct.new(:view, :modify, :admin) do + def self.parse(str) + view, modify, admin = str.split(':').map { |b| b.to_s.downcase == 'true' } + + new view, modify, admin + end + + def to_s + "#{view}:#{modify}:#{admin}" + end + end + api_path 'passwordlists' + acceptable_methods :CR index_field :password_list_id @@ -26,11 +41,13 @@ module Passwordstate :provide_access_reason, :password_reset_enabled, :force_password_generator, - :hide_passwords, + :hide_passwords, { is: HideConfig }, :show_guide, :enable_password_reset_schedule, :password_reset_schedule, - :add_days_to_expiry_date + :add_to_expiry_date, + :add_to_expiry_date_interval, + :one_time_passwords read_fields :password_list_id, { name: 'PasswordListID' }, :tree_path, @@ -38,10 +55,22 @@ module Passwordstate :generator_name, :policy_name + write_fields :copy_settings_from_password_list_id, { name: 'CopySettinsgFromPasswordListID' }, + :copy_settings_from_template_id, { name: 'CopySettingsFromTemplateID' }, + :link_to_template, + :copy_permissions_from_password_list_id, { name: 'CopyPermissionsFromPasswordListID' }, + :copy_permissions_from_template_id, { name: 'CopyPermissionsFromTemplateID' }, + :nest_under_folder_id, { name: 'NestUnderFolderID' }, + :apply_permissions_for_user_id, { name: 'ApplyPermissionsForUserID' }, + :apply_permissions_for_security_group_id, { name: 'ApplyPermissionsForSecurityGroupID' }, + :apply_permissions_for_security_group_name, + :permission, + :site_id, { name: 'SiteID' } + alias title password_list - def self.search(client, query = {}) - super client, query.merge(_api_path: 'searchpasswordlists') + def self.search(client, **query) + super client, **query.merge(_api_path: 'searchpasswordlists') end def passwords @@ -58,8 +87,11 @@ module Passwordstate PasswordListPermission.new(_client: client, password_list_id: password_list_id) end - def full_path(unix = false) - [tree_path, password_list].compact.join('\\').tap do |full| + def full_path(unix: false) + path = [tree_path] + path << password_list unless tree_path.end_with? password_list + + path.compact.join('\\').tap do |full| full.tr!('\\', '/') if unix end end @@ -70,7 +102,7 @@ module Passwordstate index_field :password_list_id - read_fields :password_list_id, { name: 'PasswordListID' } # rubocop:disable Style/BracesAroundHashParameters + read_fields :password_list_id, { name: 'PasswordListID' } end end end diff --git a/lib/passwordstate/resources/permission.rb b/lib/passwordstate/resources/permission.rb index f40aa4b51ea50787d57a80551981af0cccb5c940..11f59ba10e5632b1068ec5b82f58e28a86075c30 100644 --- a/lib/passwordstate/resources/permission.rb +++ b/lib/passwordstate/resources/permission.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate module Resources class Permission < Passwordstate::Resource diff --git a/lib/passwordstate/resources/privileged_account.rb b/lib/passwordstate/resources/privileged_account.rb new file mode 100644 index 0000000000000000000000000000000000000000..46eb70182823d6bb0f632d441f619df28faa2101 --- /dev/null +++ b/lib/passwordstate/resources/privileged_account.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Passwordstate + module Resources + class PrivilegedAccount < Passwordstate::Resource + api_path 'privaccount' + + index_field :privileged_account_id + + accessor_fields :description, + :user_name, + :password, + :password_id, { name: 'PasswordID' }, + :key_type, + :pass_phrase, + :private_key, + :site_id, { name: 'SiteID' }, + :account_type, + :enable_password + + read_fields :privileged_account_id + end + + class PrivilegedAccountPermission < Permission + api_path 'privaccountpermissions' + + index_field :privileged_account_id + + read_fields :privileged_account_id, { name: 'PrivilegedAccountID' } + end + end +end diff --git a/lib/passwordstate/resources/remote_site.rb b/lib/passwordstate/resources/remote_site.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3316b6b19dad7261654699aba8c242a47831a04 --- /dev/null +++ b/lib/passwordstate/resources/remote_site.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Passwordstate + module Resources + class RemoteSite < Passwordstate::Resource + api_path 'remotesitelocations' + + index_field :site_id + + accessor_fields :site_location, + :poll_frequency, + :maintenance_start, + :maintenance_end, + :gateway_url, { name: 'GatewayURL' }, + :purge_session_recordings, + :discovery_threads, + :allowed_ip_ranges, { name: 'AllowedIPRanges' } + + read_fields :site_id, { name: 'SiteID' } + + def self.installer_instructions + client.request :get, "#{api_path}/exportinstallerinstructions" + end + end + end +end diff --git a/lib/passwordstate/resources/report.rb b/lib/passwordstate/resources/report.rb index 1da57f7b16ec08af39aba239b7ae03b94aeeec69..9e07de638d19cb71b1ba1a8c9c7f7b8ad17486bc 100644 --- a/lib/passwordstate/resources/report.rb +++ b/lib/passwordstate/resources/report.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate module Resources class Report < Passwordstate::Resource diff --git a/lib/passwordstate/util.rb b/lib/passwordstate/util.rb index 6060136070cfdf39c39e137c1ee1f5a6abdc8fea..649a257f78c89e9bce10888a0b2ec1b2c237a48d 100644 --- a/lib/passwordstate/util.rb +++ b/lib/passwordstate/util.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'net/http' require 'net/ntlm' @@ -55,7 +57,7 @@ module Passwordstate end type1 = Net::NTLM::Message::Type1.new - req['authorization'] = 'NTLM ' + type1.encode64 + req['authorization'] = "NTLM #{type1.encode64}" res = super(req, body) challenge = res['www-authenticate'][/(?:NTLM|Negotiate) (.+)/, 1] @@ -64,7 +66,7 @@ module Passwordstate type2 = Net::NTLM::Message.decode64 challenge type3 = type2.response(req.ntlm_auth_information, req.ntlm_auth_options.dup) - req['authorization'] = 'NTLM ' + type3.encode64 + req['authorization'] = "NTLM #{type3.encode64}" req.body_stream.rewind if req.body_stream req.body = @last_body if @last_body diff --git a/lib/passwordstate/version.rb b/lib/passwordstate/version.rb index c14872cb0e34f42c5de7a9bb5b987f42babcf945..bcbec8dc07e885b9e9ce74bbcf476c701b2e94d1 100644 --- a/lib/passwordstate/version.rb +++ b/lib/passwordstate/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Passwordstate - VERSION = '0.0.4'.freeze + VERSION = '0.1.0' end diff --git a/passwordstate.gemspec b/passwordstate.gemspec index 4f8be6b1f9bec94310a5ea1f60619aef3d873e3f..b162d16b9a39a027152a47c881f2864f70da8af1 100644 --- a/passwordstate.gemspec +++ b/passwordstate.gemspec @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require File.join File.expand_path('lib', __dir__), 'passwordstate/version' Gem::Specification.new do |spec| @@ -11,15 +13,19 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/ananace/ruby-passwordstate' spec.license = 'MIT' + spec.required_ruby_version = '>= 2.7' + spec.metadata['rubygems_mfa_required'] = 'true' + spec.files = `git ls-files -z`.split("\x0") - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'logging', '~> 2.2' - spec.add_dependency 'rubyntlm', '~> 0.6' + spec.add_dependency 'logging', '~> 2' + spec.add_dependency 'rubyntlm', '~> 0' spec.add_development_dependency 'bundler' spec.add_development_dependency 'minitest' + spec.add_development_dependency 'minitest-reporters' + spec.add_development_dependency 'mocha' spec.add_development_dependency 'rake' spec.add_development_dependency 'rubocop' end diff --git a/test/client_test.rb b/test/client_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..5ec849b0b562680c8c2f8374f8dc932951236d38 --- /dev/null +++ b/test/client_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ClientTest < Minitest::Test + def setup + Net::HTTP.any_instance.expects(:start).never + + @client = Passwordstate::Client.new 'http://passwordstate.example.com' + end + + def test_version + @client.expects(:request).with(:get, '', allow_html: true).returns <<~HTML + <html> + Lorem ipsum + <div><span>V</span>9.3 (Build 9200)</div> + </html> + HTML + + assert_equal '9.3.9200', @client.version + assert @client.version? '~> 9.3' + end + + # Passwordstate version 9.6 has started doing stupid things. + # There are no longer any available method to see the version. + def test_broken_version + @client.stubs(:request).with(:get, '', allow_html: true).returns <<~HTML + + + + <script> + window.location.href = '/help/manuals/api/index.html'; + </script> + HTML + + assert_nil @client.version + assert @client.version? '~> 9.3' + end +end diff --git a/test/fixtures/get_password.json b/test/fixtures/get_password.json new file mode 100644 index 0000000000000000000000000000000000000000..3c6401e78eb0abd469230c9a37f19cff522af605 --- /dev/null +++ b/test/fixtures/get_password.json @@ -0,0 +1,31 @@ +[ + { + "PasswordListID": "100", + "PasswordList": "Managed Passwords", + "PasswordID": 18075, + "Title": "borgdrone-4673615 @ Unimatrix Zero", + "Domain": "", + "HostName": "", + "UserName": "borgdrone-4673615", + "Description": "Unimatrix Zero access key for borgdrone-4673615", + "GenericField1": "", + "GenericField2": "", + "GenericField3": "", + "GenericField4": "", + "GenericField5": "", + "GenericField6": "", + "GenericField7": "", + "GenericField8": "", + "GenericField9": "", + "GenericField10": "", + "GenericFieldInfo": [], + "AccountTypeID": 0, + "Notes": "", + "URL": "", + "Password": "weishe4uChee0woh4ahquineibahpheiquaiy2yuRohjohChee6eet6rahbaiceetucoothooph8ooKahwoh5Teepah3ieLohwahkeigh4eefeivoohee8quee5oohoe", + "ExpiryDate": "", + "AllowExport": true, + "AccountType": "", + "OTP": null + } +] diff --git a/test/fixtures/get_password_list.json b/test/fixtures/get_password_list.json new file mode 100644 index 0000000000000000000000000000000000000000..87130dcc2104684ed78d74f0207a9f35b2bb0e85 --- /dev/null +++ b/test/fixtures/get_password_list.json @@ -0,0 +1,42 @@ +[ + { + "PasswordListID": 100, + "PasswordList": "Managed Passwords", + "Description": "Someone's managed passwords", + "ImageFileName": "system.png", + "Guide": "", + "AllowExport": false, + "PrivatePasswordList": false, + "NoApprovers": "1", + "DisableNotifications": false, + "TimeBasedAccessRequired": false, + "PasswordStrengthPolicyID": 1, + "PasswordGeneratorID": 1, + "CodePage": "Using Passwordstate Default Code Page", + "PreventPasswordReuse": 0, + "AuthenticationType": "Use RADIUS Authentication", + "AuthenticationPerSession": false, + "PreventExpiryDateModification": true, + "SetExpiryDate": 0, + "ResetExpiryDate": 0, + "PreventDragDrop": true, + "PreventBadPasswordUse": false, + "ProvideAccessReason": true, + "TreePath": "\\Shared\\Somewhere\\Managed Passwords", + "TotalPasswords": 656, + "GeneratorName": "Corporate Password Generator", + "PolicyName": "Corporate Password Strength Policy", + "PasswordResetEnabled": false, + "ForcePasswordGenerator": false, + "HidePasswords": "False:True:False", + "ShowGuide": false, + "EnablePasswordResetSchedule": false, + "PasswordResetSchedule": "00:00:00:00", + "AddToExpiryDate": 90, + "AddToExpiryDateInterval": "Days", + "SiteID": 0, + "SiteLocation": "Internal", + "OneTimePasswords": false, + "DisableInheritance": true + } +] diff --git a/test/fixtures/get_password_otp.json b/test/fixtures/get_password_otp.json new file mode 100644 index 0000000000000000000000000000000000000000..3093236f94a4c410acdd828e7cf3c0eaa8210e25 --- /dev/null +++ b/test/fixtures/get_password_otp.json @@ -0,0 +1,5 @@ +[ + { + "OTP": "123456" + } +] diff --git a/test/fixtures/password_list_search_passwords.json b/test/fixtures/password_list_search_passwords.json new file mode 100644 index 0000000000000000000000000000000000000000..27d48ffde2d96f61377b7bb92b20ebcedb507b34 --- /dev/null +++ b/test/fixtures/password_list_search_passwords.json @@ -0,0 +1,60 @@ +[ + { + "PasswordListID": "100", + "PasswordList": "Managed Passwords", + "PasswordID": 18075, + "Title": "borgdrone-4673615 @ Unimatrix Zero", + "Domain": "", + "HostName": "", + "UserName": "borgdrone-4673615", + "Description": "Unimatrix Zero access key for borgdrone-4673615", + "GenericField1": "", + "GenericField2": "", + "GenericField3": "", + "GenericField4": "", + "GenericField5": "", + "GenericField6": "", + "GenericField7": "", + "GenericField8": "", + "GenericField9": "", + "GenericField10": "", + "GenericFieldInfo": [], + "AccountTypeID": 0, + "Notes": "", + "URL": "", + "Password": "weishe4uChee0woh4ahquineibahpheiquaiy2yuRohjohChee6eet6rahbaiceetucoothooph8ooKahwoh5Teepah3ieLohwahkeigh4eefeivoohee8quee5oohoe", + "ExpiryDate": "", + "AllowExport": true, + "AccountType": "", + "OTP": null + }, + { + "PasswordListID": "100", + "PasswordList": "Managed Passwords", + "PasswordID": 29467, + "Title": "borgdrone-9342756 @ Unimatrix Zero", + "Domain": "", + "HostName": "", + "UserName": "borgdrone-9342756", + "Description": "Unimatrix Zero access key for borgdrone-9342756", + "GenericField1": "", + "GenericField2": "", + "GenericField3": "", + "GenericField4": "", + "GenericField5": "", + "GenericField6": "", + "GenericField7": "", + "GenericField8": "", + "GenericField9": "", + "GenericField10": "", + "GenericFieldInfo": [], + "AccountTypeID": 0, + "Notes": "", + "URL": "", + "Password": "chaihohd6aemeitivaefei0zahjee0IN7aevaisoGohrae5ileeHaquaik7lo1aicoo3lohchae2nujohRu2oofiizieth6aezahng8ohp9siesaemahKaifah8Ooyoo", + "ExpiryDate": "", + "AllowExport": true, + "AccountType": "", + "OTP": null + } +] diff --git a/test/fixtures/update_password.json b/test/fixtures/update_password.json new file mode 100644 index 0000000000000000000000000000000000000000..dcada24d94138f3bdd8b186a01411203aeeaf0f7 --- /dev/null +++ b/test/fixtures/update_password.json @@ -0,0 +1,31 @@ +[ + { + "PasswordListID": "100", + "PasswordList": "Managed Passwords", + "PasswordID": 18075, + "Title": "borgdrone-4673615 @ Unimatrix Zero", + "Domain": "1c0389a1-6e63-4276-8500-1e595f0288e9.cube.collective", + "HostName": "", + "UserName": "borgdrone-4673615", + "Description": "Unimatrix Zero access key for borgdrone-4673615", + "GenericField1": "", + "GenericField2": "", + "GenericField3": "", + "GenericField4": "", + "GenericField5": "", + "GenericField6": "", + "GenericField7": "", + "GenericField8": "", + "GenericField9": "", + "GenericField10": "", + "GenericFieldInfo": [], + "AccountTypeID": 0, + "Notes": "", + "URL": "", + "Password": "weishe4uChee0woh4ahquineibahpheiquaiy2yuRohjohChee6eet6rahbaiceetucoothooph8ooKahwoh5Teepah3ieLohwahkeigh4eefeivoohee8quee5oohoe", + "ExpiryDate": "", + "AllowExport": true, + "AccountType": "", + "OTP": null + } +] diff --git a/test/fixtures/update_password_managed.json b/test/fixtures/update_password_managed.json new file mode 100644 index 0000000000000000000000000000000000000000..d568312296ba6accb6353ada3aabcf0ed4cd7d1d --- /dev/null +++ b/test/fixtures/update_password_managed.json @@ -0,0 +1,8 @@ +[ + { + "PasswordID": 18075, + "Status": "Password Queued for Reset(s). Check auditing data, or UI for results.", + "CurrentPassword": "weishe4uChee0woh4ahquineibahpheiquaiy2yuRohjohChee6eet6rahbaiceetucoothooph8ooKahwoh5Teepah3ieLohwahkeigh4eefeivoohee8quee5oohoe", + "NewPassword": "<redacted>" + } +] diff --git a/test/passwordstate_test.rb b/test/passwordstate_test.rb index cd068119933993f8d7b1d60733b71ad73d6fe825..d490ceab4971eb4de86de7303ba7bf035ce6a56c 100644 --- a/test/passwordstate_test.rb +++ b/test/passwordstate_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class PasswordstateTest < Minitest::Test @@ -5,7 +7,13 @@ class PasswordstateTest < Minitest::Test refute_nil ::Passwordstate::VERSION end - def test_it_does_something_useful - assert false + def test_logging_setup + assert_equal 2, Passwordstate.logger.level # :warn + + Passwordstate.debug! + + assert_equal 0, Passwordstate.logger.level # :debug + + Passwordstate.logger.level = :warn end end diff --git a/test/resources/password_list_test.rb b/test/resources/password_list_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..3aa577d8ec4d0ad5bc0d57dfda0b149c9d99e35f --- /dev/null +++ b/test/resources/password_list_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordListTest < Minitest::Test + def setup + Net::HTTP.any_instance.expects(:start).never + + @client = Passwordstate::Client.new 'http://passwordstate.example.com' + @client.expects(:request) + .with(:get, 'passwordlists/100', query: {}, reason: nil) + .returns(JSON.parse(File.read('test/fixtures/get_password_list.json'))) + + @list = @client.password_lists.get 100 + end + + # CRUD tests + def test_create + @client.expects(:request) + .with(:post, 'passwordlists', query: {}, reason: nil, body: + { + 'PasswordList' => 'Managed Passwords', + 'Description' => "Someone's managed passwords", + 'NestUnderFolderID' => 23 + }) + .returns(JSON.parse(File.read('test/fixtures/get_password_list.json'))) + + @client.password_lists.create password_list: 'Managed Passwords', + description: "Someone's managed passwords", + nest_under_folder_id: 23 + end + + def test_read + @client.expects(:request) + .with(:get, 'passwordlists/100', query: {}, reason: nil) + .returns(JSON.parse(File.read('test/fixtures/get_password_list.json'))) + + @list.get + end + + def test_update + assert_raises(Passwordstate::NotAcceptableError) { @list.put } + end + + def test_delete + assert_raises(Passwordstate::NotAcceptableError) { @list.delete } + end + + # Functionality test + def test_parsed + refute_nil @list.nil? + + assert_equal 100, @list.password_list_id + + assert_equal false, @list.hide_passwords.view + assert_equal true, @list.hide_passwords.modify + assert_equal false, @list.hide_passwords.admin + + assert_equal '/Shared/Somewhere/Managed Passwords', @list.full_path(unix: true) + end + + def test_search + @client.expects(:request) + .with(:get, 'searchpasswordlists', query: { 'SiteID' => 0 }, reason: nil) + .returns([JSON.parse(File.read('test/fixtures/get_password_list.json'))]) + + assert_equal @list.attributes, @client.password_lists.search(site_id: 0).first.attributes + end + + def test_password_search + @client.expects(:request) + .with(:get, 'searchpasswords/100', query: { 'Description' => 'borg' }, reason: nil) + .returns(JSON.parse(File.read('test/fixtures/password_list_search_passwords.json'))) + + passwords = @list.passwords.search description: 'borg' + + assert_equal 2, passwords.count + assert_equal 'borgdrone-4673615', passwords.first.user_name + assert_equal 'borgdrone-9342756', passwords.last.user_name + end +end diff --git a/test/resources/password_test.rb b/test/resources/password_test.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae7488ca891f56fcefba199b804fa34ad808ce3e --- /dev/null +++ b/test/resources/password_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PasswordTest < Minitest::Test + def setup + Net::HTTP.any_instance.expects(:start).never + + @client = Passwordstate::Client.new 'http://passwordstate.example.com' + @client.expects(:request) + .with(:get, 'passwords/18075', query: {}, reason: nil) + .returns(JSON.parse(File.read('test/fixtures/get_password.json'))) + + @password = @client.passwords.get 18_075 + end + + # CRUD tests + def test_create + @client.expects(:request) + .with(:post, 'passwords', query: {}, reason: nil, body: + { + 'PasswordListID' => 100, + 'Title' => 'borgdrone-4673615 @ Unimatrix Zero', + 'UserName' => 'borgdrone-4673615', + 'GeneratePassword' => true + }) + .returns(JSON.parse(File.read('test/fixtures/get_password.json'))) + + @client.passwords.create password_list_id: 100, + title: 'borgdrone-4673615 @ Unimatrix Zero', + user_name: 'borgdrone-4673615', + generate_password: true + end + + def test_read + @client.expects(:request) + .with(:get, 'passwords/18075', query: {}, reason: nil) + .returns(JSON.parse(File.read('test/fixtures/get_password.json'))) + + @password.get + end + + def test_update + @password.domain = '1c0389a1-6e63-4276-8500-1e595f0288e9.cube.collective' + + @client.expects(:request) + .with(:put, 'passwords', query: {}, reason: nil, body: + { + 'PasswordID' => 18_075, + 'Domain' => @password.domain + }) + .returns(JSON.parse(File.read('test/fixtures/update_password.json'))) + + @password.put + + assert_nil @password.status + + @client.expects(:request) + .with(:put, 'passwords', query: {}, reason: nil, body: + { + 'PasswordID' => 18_075, + 'Password' => '<redacted>' + }) + .returns(JSON.parse(File.read('test/fixtures/update_password_managed.json'))) + + @password.password = '<redacted>' + @password.put + + refute_nil @password.status + assert_equal '<redacted>', @password.new_password + refute_equal '<redacted>', @password.current_password + end + + def test_delete + @client.expects(:request) + .with(:delete, 'passwords/18075', query: { 'MoveToRecycleBin' => false }, reason: nil) + .returns(true) + + @password.delete + + @client.expects(:request) + .with(:get, 'passwords/18075', query: {}, reason: nil) + .raises(Passwordstate::HTTPError.new_by_code(404, nil, nil)) + + assert_raises(Passwordstate::NotFoundError) { @password.get } + end + + # Functionality test + def test_otp + @client.expects(:request) + .with(:get, 'onetimepassword/18075') + .returns(JSON.parse(File.read('test/fixtures/get_password_otp.json'))) + + assert_nil @password.otp + assert_equal '123456', @password.otp! + end + + def test_check_in + @client.expects(:request) + .with(:get, 'passwords/18075', query: { 'CheckIn' => nil }) + .returns(true) + + @password.check_in + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index aae52c9b1fbee5a1b1158c53b96e834b43a10351..a5491f3c275f23c7e8a5ddd98b602b5ee1f4f7df 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,12 @@ +# frozen_string_literal: true + $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'passwordstate' +require 'minitest/reporters' +Minitest::Reporters.use! [ + Minitest::Reporters::ProgressReporter.new, + Minitest::Reporters::JUnitReporter.new +] require 'minitest/autorun' +require 'mocha/minitest'