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'