Repository: ansible-collections/community.general

Issue:
1Password lookup is broken with cli v2
### Summary

1Password recently updated their cli to v2 and that brings with it breaking changes - lookups are failing with `Unable to sign in to 1Password. master_password is required` when authenticated and `op` cli commands return results successfully otherwise.

So far I have noticed a couple changes/issues:
 - The config appears to have moved from `~/.op/config` to `~/.config/op/config` 
- https://github.com/ansible-collections/community.general/blob/main/plugins/lookup/onepassword.py#L147 is failing since the `op get account` command no longer exists but `op account get` does.




### Issue Type

Bug Report

### Component Name

1Password lookup

### Ansible Version

```
ansible [core 2.12.3]
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/user/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3.10/site-packages/ansible
  ansible collection location = /home/user/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.10.2 (main, Jan 15 2022, 19:56:27) [GCC 11.1.0]
  jinja version = 3.0.3
  libyaml = True
```


### Community.general Version

```
community.general 4.6.0
```


### Configuration

_No response_

### OS / Environment

Arch

### Steps to Reproduce

Update to 1password cli v2 and attempt to use a 1password lookup after authenticating with `op signin`
```
lookup('onepassword', 'some_item', field='some_field', vault='some_vault')
```


### Expected Results

Get secrets from 1password

### Actual Results

Got errors due to changes in cli commands and config paths

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct
Support 1password cli v2 lookups
##### SUMMARY
Fixes #4396
Adds support for 1password cli v2. A version check is added before attempting to pull any secrets from 1password to determine the correct commands to run. 

##### ISSUE TYPE
- Bugfix Pull Request

##### COMPONENT NAME
plugins/lookup/onepassword.py

##### ADDITIONAL INFORMATION
The logic flow is the same otherwise so use with 1password cli < 2 is unaffected by the changes, if the version is less then 2 the original commands are run, otherwise the v2 commands are used. 

1password cli v2 does not appear to support the `full_login` workflow so an error is thrown if that is attempted, partial login with just the password does work. I don't understand either use case as it would end up with your 1pass secrets in your playbook which seems to defeat the purpose of using an external secret store. 

The onepassword_raw.py lookup was not touched as that simply returns the raw output from 1password, although this will be different for different versions of the cli.



Discussion:
Files identified in the description:
* `lib/ansible/plugins/lookup`

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
!component =plugins/lookup/onepassword.py
!component +plugins/lookup/onepassword_raw.py
Files identified in the description:
* [`plugins/lookup/onepassword.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/lookup/onepassword.py)
* [`plugins/lookup/onepassword_raw.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/lookup/onepassword_raw.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @azenk @samdoran @scottsb
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
cc @azenk @samdoran @scottsb
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
PS: This is borderline between feature (support for v2) and bugfix; I'm tending more to feature right now. Please use the `minor_changes` section for the changelog fragment.
> I don't understand either use case as it would end up with your 1pass secrets in your playbook which seems to defeat the purpose of using an external secret store.

The secret store authentication credentials could be stored in an Ansible Vault. The Ansible Vault key can be stored in AWX/Ansible Tower, allowing automated yet secure access to the secret store.
> > I don't understand either use case as it would end up with your 1pass secrets in your playbook which seems to defeat the purpose of using an external secret store.
> 
> The secret store authentication credentials could be stored in an Ansible Vault. The Ansible Vault key can be stored in AWX/Ansible Tower, allowing automated yet secure access to the secret store.

Yeah - I understand that. I guess was just thinking why use 1pass for secrets if you're already using vault. I prefer not to have any secrets committed encrypted or otherwise. 
I see the tests are failing due to how/where I did the version check. I'll move that around and get those working.
It's unfortunate that v2 has a number of breaking changes, both in the CLI and also the structure of the returned data. I'm thinking it would be better to have separate classes for each version, such as `OnePassword_v1`, rather than conditionals sprinkled throughout the methods. This would also allow for easier maintenance if there are more breaking changes in v3 and beyond.
I considered that (and adding v2 methods) but was also just trying to get this working again so I could continue using the lookup with the new version and there are already a bunch of conditionals for building the various commands. I like the separate class option as that removes the need to do any compatibility testing with different versions. Assuming we would still only want a single `1password` lookup option another class could be added and the correct version determined in the LookupModue class?

At least with the returned data now if you are requesting a specific field the json seems consistent regardless of the object type.

I definitely like the schema changes in v2, it's just unfortunate that it's a breaking change.

The `OnePassword()` call would instantiate the correct version class based on the result of the `get_version()` method.

Hello there,
are you still polishing this solution or is it abandoned? I use 1password heavily so I am ready to participate.
basically it works for me. at least the unit tests must be adjusted and a changelog fragment must be added (_imo_).  
everything else (_separate v2 methods or improve config/version detection_) is cosmetics (_imo_).
Without a changelog fragment and working tests this won't get merged anyway.
I'm working on a more comprehensive solution to this.
> I'm working on a more comprehensive solution to this.

@samdoran are you willing to complete also the v2 support?
Yup.
Been a hectic week. Still working on this.

Resolving patch:
diff --git a/plugins/lookup/onepassword.py b/plugins/lookup/onepassword.py
--- a/plugins/lookup/onepassword.py
+++ b/plugins/lookup/onepassword.py
@@ -55,7 +55,7 @@
       - This lookup stores potentially sensitive data from 1Password as Ansible facts.
         Facts are subject to caching if enabled, which means this data could be stored in clear text
         on disk or in a database.
-      - Tested with C(op) version 0.5.3
+      - Tested with C(op) version 2.7.2
 '''
 
 EXAMPLES = """
@@ -96,106 +96,123 @@
     elements: str
 """
 
-import errno
-import json
+import abc
 import os
-
-from subprocess import Popen, PIPE
+import json
+import subprocess
 
 from ansible.plugins.lookup import LookupBase
 from ansible.errors import AnsibleLookupError
+from ansible.module_utils.common.process import get_bin_path
 from ansible.module_utils.common.text.converters import to_bytes, to_text
+from ansible.module_utils.six import with_metaclass
 
 from ansible_collections.community.general.plugins.module_utils.onepassword import OnePasswordConfig
 
 
-class OnePass(object):
-    def __init__(self, path='op'):
-        self.cli_path = path
-        self.logged_in = False
-        self.token = None
-        self.subdomain = None
-        self.domain = None
-        self.username = None
-        self.secret_key = None
-        self.master_password = None
+class OnePassCLIBase(with_metaclass(abc.ABCMeta, object)):
+    bin = "op"
+
+    def __init__(self, subdomain=None, domain="1password.com", username=None, secret_key=None, master_password=None):
+        self.subdomain = subdomain
+        self.domain = domain
+        self.username = username
+        self.master_password = master_password
+        self.secret_key = secret_key
+
+        self._path = None
+        self._version = None
+
+    def _check_required_params(self, required_params):
+        non_empty_attrs = dict((param, getattr(self, param, None)) for param in required_params if getattr(self, param, None))
+        missing = set(required_params).difference(non_empty_attrs)
+        if missing:
+            prefix = "Unable to sign in to 1Password. Missing required parameter"
+            plural = ""
+            suffix = ": {params}.".format(params=", ".join(missing))
+            if len(missing) > 1:
+                plural = "s"
+
+            msg = "{prefix}{plural}{suffix}".format(prefix=prefix, plural=plural, suffix=suffix)
+            raise AnsibleLookupError(msg)
+
+    @abc.abstractmethod
+    def _parse_field(self, data_json, field_name, section_title):
+        """Main method for parsing data returned from the op command line tool"""
+
+    def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False, environment_update=None):
+        command = [self.path] + args
+        call_kwargs = {
+            "stdout": subprocess.PIPE,
+            "stderr": subprocess.PIPE,
+            "stdin": subprocess.PIPE,
+        }
 
-        self._config = OnePasswordConfig()
+        if environment_update:
+            env = os.environ.copy()
+            env.update(environment_update)
+            call_kwargs["env"] = env
 
-    def get_token(self):
-        # If the config file exists, assume an initial signin has taken place and try basic sign in
-        if os.path.isfile(self._config.config_file_path):
+        p = subprocess.Popen(command, **call_kwargs)
+        out, err = p.communicate(input=command_input)
+        rc = p.wait()
 
-            if not self.master_password:
-                raise AnsibleLookupError('Unable to sign in to 1Password. master_password is required.')
+        if not ignore_errors and rc != expected_rc:
+            raise AnsibleLookupError(to_text(err))
 
-            try:
-                args = ['signin', '--output=raw']
+        return rc, out, err
 
-                if self.subdomain:
-                    args = ['signin', self.subdomain, '--output=raw']
+    @abc.abstractmethod
+    def assert_logged_in(self):
+        """Check whether a login session exists"""
 
-                rc, out, err = self._run(args, command_input=to_bytes(self.master_password))
-                self.token = out.strip()
+    @abc.abstractmethod
+    def full_signin(self):
+        """Performa full login"""
 
-            except AnsibleLookupError:
-                self.full_login()
+    @abc.abstractmethod
+    def get_raw(self, item_id, vault=None, token=None):
+        """Gets the specified item from the vault"""
 
-        else:
-            # Attempt a full sign in since there appears to be no existing sign in
-            self.full_login()
+    @abc.abstractmethod
+    def signin(self):
+        """Sign in using the master password"""
 
-    def assert_logged_in(self):
-        try:
-            rc, out, err = self._run(['get', 'account'], ignore_errors=True)
-            if rc == 0:
-                self.logged_in = True
-            if not self.logged_in:
-                self.get_token()
-        except OSError as e:
-            if e.errno == errno.ENOENT:
-                raise AnsibleLookupError("1Password CLI tool '%s' not installed in path on control machine" % self.cli_path)
-            raise e
+    @property
+    def path(self):
+        if self._path is None:
+            self._path = get_bin_path(self.bin)
 
-    def get_raw(self, item_id, vault=None):
-        args = ["get", "item", item_id]
-        if vault is not None:
-            args += ['--vault={0}'.format(vault)]
-        if not self.logged_in:
-            args += [to_bytes('--session=') + self.token]
-        rc, output, dummy = self._run(args)
-        return output
+        return self._path
 
-    def get_field(self, item_id, field, section=None, vault=None):
-        output = self.get_raw(item_id, vault)
-        return self._parse_field(output, field, section) if output != '' else ''
+    @property
+    def version(self):
+        if self._version is None:
+            self._version = self.get_current_version()
 
-    def full_login(self):
-        if None in [self.subdomain, self.username, self.secret_key, self.master_password]:
-            raise AnsibleLookupError('Unable to perform initial sign in to 1Password. '
-                                     'subdomain, username, secret_key, and master_password are required to perform initial sign in.')
+        return self._version
 
-        args = [
-            'signin',
-            '{0}.{1}'.format(self.subdomain, self.domain),
-            to_bytes(self.username),
-            to_bytes(self.secret_key),
-            '--output=raw',
-        ]
+    @classmethod
+    def get_current_version(cls):
+        """Standalone method to get the op CLI version. Useful when determining which class to load
+        based on the current version."""
+        try:
+            bin_path = get_bin_path(cls.bin)
+        except ValueError:
+            raise AnsibleLookupError("Unable to locate '%s' command line tool" % cls.bin)
 
-        rc, out, err = self._run(args, command_input=to_bytes(self.master_password))
-        self.token = out.strip()
+        try:
+            b_out = subprocess.check_output([bin_path, "--version"], stderr=subprocess.PIPE)
+        except subprocess.CalledProcessError as cpe:
+            raise AnsibleLookupError("Unable to get the op version: %s" % cpe)
 
-    def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False):
-        command = [self.cli_path] + args
-        p = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
-        out, err = p.communicate(input=command_input)
-        rc = p.wait()
-        if not ignore_errors and rc != expected_rc:
-            raise AnsibleLookupError(to_text(err))
-        return rc, out, err
+        return to_text(b_out).strip()
 
-    def _parse_field(self, data_json, field_name, section_title=None):
+
+class OnePassCLIv1(OnePassCLIBase):
+    supports_version = "1"
+
+    def _parse_field(self, data_json, field_name, section_title):
         """
         Retrieves the desired field from the `op` response payload
 
@@ -249,36 +266,356 @@ def _parse_field(self, data_json, field_name, section_title=None):
             # check the details dictionary for `field_name` and return it immediately if it exists
             # when the entry is a "password" instead of a "login" item, the password field is a key
             # in the `details` dictionary:
-            if field_name in data['details']:
-                return data['details'][field_name]
+            if field_name in data["details"]:
+                return data["details"][field_name]
 
             # when the field is not found above, iterate through the fields list in the object details
-            for field_data in data['details'].get('fields', []):
-                if field_data.get('name', '').lower() == field_name.lower():
-                    return field_data.get('value', '')
-        for section_data in data['details'].get('sections', []):
-            if section_title is not None and section_title.lower() != section_data['title'].lower():
+            for field_data in data["details"].get("fields", []):
+                if field_data.get("name", "").lower() == field_name.lower():
+                    return field_data.get("value", "")
+
+        for section_data in data["details"].get("sections", []):
+            if section_title is not None and section_title.lower() != section_data["title"].lower():
                 continue
-            for field_data in section_data.get('fields', []):
-                if field_data.get('t', '').lower() == field_name.lower():
-                    return field_data.get('v', '')
-        return ''
 
+            for field_data in section_data.get("fields", []):
+                if field_data.get("t", "").lower() == field_name.lower():
+                    return field_data.get("v", "")
 
-class LookupModule(LookupBase):
+        return ""
 
-    def run(self, terms, variables=None, **kwargs):
-        op = OnePass()
+    def assert_logged_in(self):
+        args = ["get", "account"]
+        if self.subdomain:
+            account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain)
+            args.extend(["--account", account])
+
+        rc, out, err = self._run(args, ignore_errors=True)
+
+        return not bool(rc)
+
+    def full_signin(self):
+        required_params = [
+            "subdomain",
+            "username",
+            "secret_key",
+            "master_password",
+        ]
+        self._check_required_params(required_params)
+
+        args = [
+            "signin",
+            "{0}.{1}".format(self.subdomain, self.domain),
+            to_bytes(self.username),
+            to_bytes(self.secret_key),
+            "--raw",
+        ]
+
+        return self._run(args, command_input=to_bytes(self.master_password))
+
+    def get_raw(self, item_id, vault=None, token=None):
+        args = ["get", "item", item_id]
+        if vault is not None:
+            args += ["--vault={0}".format(vault)]
+
+        if token is not None:
+            args += [to_bytes("--session=") + token]
+
+        return self._run(args)
+
+    def signin(self):
+        self._check_required_params(['master_password'])
+
+        args = ["signin", "--raw"]
+        if self.subdomain:
+            args.append(self.subdomain)
+
+        return self._run(args, command_input=to_bytes(self.master_password))
+
+
+class OnePassCLIv2(OnePassCLIBase):
+    """
+    CLIv2 Syntax Reference: https://developer.1password.com/docs/cli/upgrade#step-2-update-your-scripts
+    """
+    supports_version = "2"
+
+    def _parse_field(self, data_json, field_name, section_title=None):
+        """
+        Schema reference: https://developer.1password.com/docs/cli/item-template-json
+
+        Example Data:
+
+            # Password item
+            {
+              "id": "ywvdbojsguzgrgnokmcxtydgdv",
+              "title": "Authy Backup",
+              "version": 1,
+              "vault": {
+                "id": "bcqxysvcnejjrwzoqrwzcqjqxc",
+                "name": "Personal"
+              },
+              "category": "PASSWORD",
+              "last_edited_by": "7FUPZ8ZNE02KSHMAIMKHIVUE17",
+              "created_at": "2015-01-18T13:13:38Z",
+              "updated_at": "2016-02-20T16:23:54Z",
+              "additional_information": "Jan 18, 2015, 08:13:38",
+              "fields": [
+                {
+                  "id": "password",
+                  "type": "CONCEALED",
+                  "purpose": "PASSWORD",
+                  "label": "password",
+                  "value": "OctoberPoppyNuttyDraperySabbath",
+                  "reference": "op://Personal/Authy Backup/password",
+                  "password_details": {
+                    "strength": "FANTASTIC"
+                  }
+                },
+                {
+                  "id": "notesPlain",
+                  "type": "STRING",
+                  "purpose": "NOTES",
+                  "label": "notesPlain",
+                  "value": "Backup password to restore Authy",
+                  "reference": "op://Personal/Authy Backup/notesPlain"
+                }
+              ]
+            }
+
+            # Login item
+            {
+              "id": "awk4s2u44fhnrgppszcsvc663i",
+              "title": "Dummy Login",
+              "version": 2,
+              "vault": {
+                "id": "stpebbaccrq72xulgouxsk4p7y",
+                "name": "Personal"
+              },
+              "category": "LOGIN",
+              "last_edited_by": "LSGPJERUYBH7BFPHMZ2KKGL6AU",
+              "created_at": "2018-04-25T21:55:19Z",
+              "updated_at": "2018-04-25T21:56:06Z",
+              "additional_information": "agent.smith",
+              "urls": [
+                {
+                  "primary": true,
+                  "href": "https://acme.com"
+                }
+              ],
+              "sections": [
+                {
+                  "id": "linked items",
+                  "label": "Related Items"
+                }
+              ],
+              "fields": [
+                {
+                  "id": "username",
+                  "type": "STRING",
+                  "purpose": "USERNAME",
+                  "label": "username",
+                  "value": "agent.smith",
+                  "reference": "op://Personal/Dummy Login/username"
+                },
+                {
+                  "id": "password",
+                  "type": "CONCEALED",
+                  "purpose": "PASSWORD",
+                  "label": "password",
+                  "value": "Q7vFwTJcqwxKmTU]Dzx7NW*wrNPXmj",
+                  "entropy": 159.6083697084228,
+                  "reference": "op://Personal/Dummy Login/password",
+                  "password_details": {
+                    "entropy": 159,
+                    "generated": true,
+                    "strength": "FANTASTIC"
+                  }
+                },
+                {
+                  "id": "notesPlain",
+                  "type": "STRING",
+                  "purpose": "NOTES",
+                  "label": "notesPlain",
+                  "reference": "op://Personal/Dummy Login/notesPlain"
+                }
+              ]
+            }
+        """
+        data = json.loads(data_json)
+        for field in data.get("fields", []):
+            if section_title is None:
+                # If the field name exists in the section, return that value
+                if field.get(field_name):
+                    return field.get(field_name)
+
+                # If the field name doesn't exist in the section, match on the value of "label"
+                # then "id" and return "value"
+                if field.get("label") == field_name:
+                    return field["value"]
+
+                if field.get("id") == field_name:
+                    return field["value"]
+
+            # Look at the section data and get an indentifier. The value of 'id' is either a unique ID
+            # or a human-readable string. If a 'label' field exists, prefer that since
+            # it is the value visible in the 1Password UI when both 'id' and 'label' exist.
+            section = field.get("section", {})
+            current_section_title = section.get("label", section.get("id"))
+            if section_title == current_section_title:
+                # In the correct section. Check "label" then "id" for the desired field_name
+                if field.get("label") == field_name:
+                    return field["value"]
+
+                if field.get("id") == field_name:
+                    return field["value"]
+
+        return ""
+
+    def assert_logged_in(self):
+        args = ["account", "list"]
+        if self.subdomain:
+            account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain)
+            args.extend(["--account", account])
+
+        rc, out, err = self._run(args)
+
+        if out:
+            # Running 'op account get' if there are no accounts configured on the system drops into
+            # an interactive prompt. Only run 'op account get' after first listing accounts to see
+            # if there are any previously configured accounts.
+            args = ["account", "get"]
+            if self.subdomain:
+                account = "{subdomain}.{domain}".format(subdomain=self.subdomain, domain=self.domain)
+                args.extend(["--account", account])
+
+            rc, out, err = self._run(args)
+
+            return not bool(rc)
+
+        return False
+
+    def full_signin(self):
+        required_params = [
+            "subdomain",
+            "username",
+            "secret_key",
+            "master_password",
+        ]
+        self._check_required_params(required_params)
+
+        args = [
+            "account", "add", "--raw",
+            "--address", "{0}.{1}".format(self.subdomain, self.domain),
+            "--email", to_bytes(self.username),
+            "--signin",
+        ]
+
+        environment_update = {"OP_SECRET_KEY": self.secret_key}
+        return self._run(args, command_input=to_bytes(self.master_password), environment_update=environment_update)
+
+    def get_raw(self, item_id, vault=None, token=None):
+        args = ["item", "get", item_id, "--format", "json"]
+        if vault is not None:
+            args += ["--vault={0}".format(vault)]
+        if token is not None:
+            args += [to_bytes("--session=") + token]
+
+        return self._run(args)
+
+    def signin(self):
+        self._check_required_params(['master_password'])
+
+        args = ["signin", "--raw"]
+        if self.subdomain:
+            args.extend(["--account", self.subdomain])
 
-        field = kwargs.get('field', 'password')
-        section = kwargs.get('section')
-        vault = kwargs.get('vault')
-        op.subdomain = kwargs.get('subdomain')
-        op.domain = kwargs.get('domain', '1password.com')
-        op.username = kwargs.get('username')
-        op.secret_key = kwargs.get('secret_key')
-        op.master_password = kwargs.get('master_password', kwargs.get('vault_password'))
+        return self._run(args, command_input=to_bytes(self.master_password))
 
+
+class OnePass(object):
+    def __init__(self, subdomain=None, domain="1password.com", username=None, secret_key=None, master_password=None):
+        self.subdomain = subdomain
+        self.domain = domain
+        self.username = username
+        self.secret_key = secret_key
+        self.master_password = master_password
+
+        self.logged_in = False
+        self.token = None
+
+        self._config = OnePasswordConfig()
+        self._cli = self._get_cli_class()
+
+    def _get_cli_class(self):
+        version = OnePassCLIBase.get_current_version()
+        for cls in OnePassCLIBase.__subclasses__():
+            if cls.supports_version == version.split(".")[0]:
+                try:
+                    return cls(self.subdomain, self.domain, self.username, self.secret_key, self.master_password)
+                except TypeError as e:
+                    raise AnsibleLookupError(e)
+
+        raise AnsibleLookupError("op version %s is unsupported" % version)
+
+    def set_token(self):
+        if self._config.config_file_path and os.path.isfile(self._config.config_file_path):
+            # If the config file exists, assume an initial sign in has taken place and try basic sign in
+            try:
+                rc, out, err = self._cli.signin()
+            except AnsibleLookupError as exc:
+                test_strings = (
+                    "missing required parameters",
+                    "unauthorized",
+                )
+                if any(string in exc.message.lower() for string in test_strings):
+                    # A required parameter is missing, or a bad master password was supplied
+                    # so don't bother attempting a full signin
+                    raise
+
+                rc, out, err = self._cli.full_signin()
+
+            self.token = out.strip()
+
+        else:
+            # Attempt a full signin since there appears to be no existing signin
+            rc, out, err = self._cli.full_signin()
+            self.token = out.strip()
+
+    def assert_logged_in(self):
+        logged_in = self._cli.assert_logged_in()
+        if logged_in:
+            self.logged_in = logged_in
+            pass
+        else:
+            self.set_token()
+
+    def get_raw(self, item_id, vault=None):
+        rc, out, err = self._cli.get_raw(item_id, vault, self.token)
+        return out
+
+    def get_field(self, item_id, field, section=None, vault=None):
+        output = self.get_raw(item_id, vault)
+        if output:
+            return self._cli._parse_field(output, field, section)
+
+        return ""
+
+
+class LookupModule(LookupBase):
+
+    def run(self, terms, variables=None, **kwargs):
+        self.set_options(var_options=variables, direct=kwargs)
+
+        field = self.get_option("field")
+        section = self.get_option("section")
+        vault = self.get_option("vault")
+        subdomain = self.get_option("subdomain")
+        domain = self.get_option("domain")
+        username = self.get_option("username")
+        secret_key = self.get_option("secret_key")
+        master_password = self.get_option("master_password")
+
+        op = OnePass(subdomain, domain, username, secret_key, master_password)
         op.assert_logged_in()
 
         values = []
diff --git a/plugins/lookup/onepassword_raw.py b/plugins/lookup/onepassword_raw.py
--- a/plugins/lookup/onepassword_raw.py
+++ b/plugins/lookup/onepassword_raw.py
@@ -47,7 +47,7 @@
       - This lookup stores potentially sensitive data from 1Password as Ansible facts.
         Facts are subject to caching if enabled, which means this data could be stored in clear text
         on disk or in a database.
-      - Tested with C(op) version 0.5.3
+      - Tested with C(op) version 2.7.0
 '''
 
 EXAMPLES = """
@@ -76,18 +76,21 @@
 class LookupModule(LookupBase):
 
     def run(self, terms, variables=None, **kwargs):
-        op = OnePass()
+        self.set_options(var_options=variables, direct=kwargs)
 
-        vault = kwargs.get('vault')
-        op.subdomain = kwargs.get('subdomain')
-        op.username = kwargs.get('username')
-        op.secret_key = kwargs.get('secret_key')
-        op.master_password = kwargs.get('master_password', kwargs.get('vault_password'))
+        vault = self.get_option("vault")
+        subdomain = self.get_option("subdomain")
+        domain = self.get_option("domain", "1password.com")
+        username = self.get_option("username")
+        secret_key = self.get_option("secret_key")
+        master_password = self.get_option("master_password")
 
+        op = OnePass(subdomain, domain, username, secret_key, master_password)
         op.assert_logged_in()
 
         values = []
         for term in terms:
             data = json.loads(op.get_raw(term, vault))
             values.append(data)
+
         return values


Test patch:
diff --git a/tests/unit/plugins/lookup/onepassword/__init__.py b/tests/unit/plugins/lookup/onepassword/__init__.py
new file mode 100644
diff --git a/tests/unit/plugins/lookup/onepassword/common.py b/tests/unit/plugins/lookup/onepassword/common.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/common.py
@@ -0,0 +1,85 @@
+# Copyright (c) 2022 Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+import json
+
+from ansible_collections.community.general.plugins.lookup.onepassword import (
+    OnePassCLIv1,
+    OnePassCLIv2,
+)
+
+
+def load_file(file):
+    with open((os.path.join(os.path.dirname(__file__), "fixtures", file)), "r") as f:
+        return json.loads(f.read())
+
+
+# Intentionally excludes metadata leaf nodes that would exist in real output if not relevant.
+MOCK_ENTRIES = {
+    OnePassCLIv1: [
+        {
+            'vault_name': 'Acme "Quot\'d" Servers',
+            'queries': [
+                '0123456789',
+                'Mock "Quot\'d" Server'
+            ],
+            'expected': ['t0pS3cret', 't0pS3cret'],
+            'output': load_file("v1_out_01.json"),
+        },
+        {
+            'vault_name': 'Acme Logins',
+            'queries': [
+                '9876543210',
+                'Mock Website',
+                'acme.com'
+            ],
+            'expected': ['t0pS3cret', 't0pS3cret', 't0pS3cret'],
+            'output': load_file("v1_out_02.json"),
+        },
+        {
+            'vault_name': 'Acme Logins',
+            'queries': [
+                '864201357'
+            ],
+            'expected': ['vauxhall'],
+            'output': load_file("v1_out_03.json"),
+        },
+    ],
+    OnePassCLIv2: [
+        {
+            "vault_name": "Test Vault",
+            "queries": [
+                "ywvdbojsguzgrgnokmcxtydgdv",
+                "Authy Backup",
+            ],
+            "expected": ["OctoberPoppyNuttyDraperySabbath", "OctoberPoppyNuttyDraperySabbath"],
+            "output": load_file("v2_out_01.json"),
+        },
+        {
+            # Request a custom field where ID and label are different
+            "vault_name": "Test Vault",
+            "queries": ["Dummy Login"],
+            "kwargs": {
+                "field": "password1",
+            },
+            "expected": ["data in custom field"],
+            "output": load_file("v2_out_02.json")
+        },
+        {
+            # Request data from a custom section
+            "vault_name": "Test Vault",
+            "queries": ["Duplicate Sections"],
+            "kwargs": {
+                "field": "s2 text",
+                "section": "Section 2",
+            },
+            "expected": ["first value"],
+            "output": load_file("v2_out_03.json")
+        },
+    ],
+}
diff --git a/tests/unit/plugins/lookup/onepassword/conftest.py b/tests/unit/plugins/lookup/onepassword/conftest.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/conftest.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2020 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+from ansible_collections.community.general.plugins.lookup.onepassword import OnePass
+
+
+OP_VERSION_FIXTURES = [
+    "opv1",
+    "opv2"
+]
+
+
+@pytest.fixture
+def fake_op(mocker):
+    def _fake_op(version):
+        mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePassCLIBase.get_current_version", return_value=version)
+        op = OnePass(None, None, None, None, None)
+        op._config._config_file_path = "/home/jin/.op/config"
+        mocker.patch.object(op._cli, "_run")
+
+        return op
+
+    return _fake_op
+
+
+@pytest.fixture
+def opv1(fake_op):
+    return fake_op("1.17.2")
+
+
+@pytest.fixture
+def opv2(fake_op):
+    return fake_op("2.27.2")
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json
@@ -0,0 +1,18 @@
+{
+    "uuid": "0123456789",
+    "vaultUuid": "2468",
+    "overview": {
+        "title": "Mock \"Quot'd\" Server"
+    },
+    "details": {
+        "sections": [{
+            "title": "",
+            "fields": [
+                {"t": "username", "v": "jamesbond"},
+                {"t": "password", "v": "t0pS3cret"},
+                {"t": "notes", "v": "Test note with\nmultiple lines and trailing space.\n\n"},
+                {"t": "tricksy \"quot'd\" field\\", "v": "\"quot'd\" value"}
+            ]
+        }]
+    }
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_01.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json
@@ -0,0 +1,18 @@
+{
+    "uuid": "9876543210",
+    "vaultUuid": "1357",
+    "overview": {
+        "title": "Mock Website",
+        "URLs": [
+            {"l": "website", "u": "https://acme.com/login"}
+        ]
+    },
+    "details": {
+        "sections": [{
+            "title": "",
+            "fields": [
+                {"t": "password", "v": "t0pS3cret"}
+            ]
+        }]
+    }
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_02.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json
@@ -0,0 +1,20 @@
+{
+    "uuid": "864201357",
+    "vaultUuid": "1357",
+    "overview": {
+        "title": "Mock Something"
+    },
+    "details": {
+        "fields": [
+            {
+                "value": "jbond@mi6.gov.uk",
+                "name": "emailAddress"
+            },
+            {
+                "name": "password",
+                "value": "vauxhall"
+            },
+            {}
+        ]
+    }
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v1_out_03.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json
@@ -0,0 +1,35 @@
+{
+    "id": "ywvdbojsguzgrgnokmcxtydgdv",
+    "title": "Authy Backup",
+    "version": 1,
+    "vault": {
+        "id": "bcqxysvcnejjrwzoqrwzcqjqxc",
+        "name": "test vault"
+    },
+    "category": "PASSWORD",
+    "last_edited_by": "7FUPZ8ZNE02KSHMAIMKHIVUE17",
+    "created_at": "2015-01-18T13:13:38Z",
+    "updated_at": "2016-02-20T16:23:54Z",
+    "additional_information": "Jan 18, 2015, 08:13:38",
+    "fields": [
+        {
+            "id": "password",
+            "type": "CONCEALED",
+            "purpose": "PASSWORD",
+            "label": "password",
+            "value": "OctoberPoppyNuttyDraperySabbath",
+            "reference": "op://Test Vault/Authy Backup/password",
+            "password_details": {
+                "strength": "FANTASTIC"
+            }
+        },
+        {
+            "id": "notesPlain",
+            "type": "STRING",
+            "purpose": "NOTES",
+            "label": "notesPlain",
+            "value": "Backup password to restore Authy",
+            "reference": "op://Test Vault/Authy Backup/notesPlain"
+        }
+    ]
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_01.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json
@@ -0,0 +1,85 @@
+{
+    "id": "awk4s2u44fhnrgppszcsvc663i",
+    "title": "Dummy Login",
+    "version": 4,
+    "vault": {
+        "id": "stpebbaccrq72xulgouxsk4p7y",
+        "name": "Personal"
+    },
+    "category": "LOGIN",
+    "last_edited_by": "LSGPJERUYBH7BFPHMZ2KKGL6AU",
+    "created_at": "2018-04-25T21:55:19Z",
+    "updated_at": "2022-09-02T17:51:21Z",
+    "additional_information": "agent.smith",
+    "urls": [
+        {
+            "primary": true,
+            "href": "https://acme.com"
+        }
+    ],
+    "sections": [
+        {
+            "id": "add more"
+        },
+        {
+            "id": "gafaeg7vnqmgrklw5r6yrufyxy",
+            "label": "COMMANDS"
+        },
+        {
+            "id": "linked items",
+            "label": "Related Items"
+        }
+    ],
+    "fields": [
+        {
+            "id": "username",
+            "type": "STRING",
+            "purpose": "USERNAME",
+            "label": "username",
+            "value": "agent.smith",
+            "reference": "op://Personal/Dummy Login/username"
+        },
+        {
+            "id": "password",
+            "type": "CONCEALED",
+            "purpose": "PASSWORD",
+            "label": "password",
+            "value": "FootworkDegreeReverence",
+            "entropy": 159.60836791992188,
+            "reference": "op://Personal/Dummy Login/password",
+            "password_details": {
+                "entropy": 159,
+                "generated": true,
+                "strength": "FANTASTIC"
+            }
+        },
+        {
+            "id": "notesPlain",
+            "type": "STRING",
+            "purpose": "NOTES",
+            "label": "notesPlain",
+            "reference": "op://Personal/Dummy Login/notesPlain"
+        },
+        {
+            "id": "7gyjekelk24ghgd4rvafspjbli",
+            "section": {
+                "id": "add more"
+            },
+            "type": "STRING",
+            "label": "title",
+            "value": "value of the field",
+            "reference": "op://Personal/Dummy Login/add more/title"
+        },
+        {
+            "id": "fx4wpzokrxn7tlb3uwpdjfptgm",
+            "section": {
+                "id": "gafaeg7vnqmgrklw5r6yrufyxy",
+                "label": "COMMANDS"
+            },
+            "type": "CONCEALED",
+            "label": "password1",
+            "value": "data in custom field",
+            "reference": "op://Personal/Dummy Login/COMMANDS/password1"
+        }
+    ]
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_02.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json
@@ -0,0 +1,103 @@
+{
+  "id": "7t7qu2r35qyvqj3crujd4dqxmy",
+  "title": "Duplicate Sections",
+  "version": 3,
+  "vault": {
+    "id": "stpebbaccrq72xulgouxsk4p7y",
+    "name": "Personal"
+  },
+  "category": "LOGIN",
+  "last_edited_by": "LSGPJERUYBH7BFPHMZ2KKGL6AU",
+  "created_at": "2022-11-04T17:09:18Z",
+  "updated_at": "2022-11-04T17:22:19Z",
+  "additional_information": "flora",
+  "urls": [
+    {
+      "label": "website",
+      "primary": true,
+      "href": "https://acme.com/login"
+    }
+  ],
+  "sections": [
+    {
+      "id": "add more"
+    },
+    {
+      "id": "7osqcvd43i75teocdzbb6d7mie",
+      "label": "Section 2"
+    }
+  ],
+  "fields": [
+    {
+      "id": "username",
+      "type": "STRING",
+      "purpose": "USERNAME",
+      "label": "username",
+      "value": "flora",
+      "reference": "op://Personal/Duplicate Sections/username"
+    },
+    {
+      "id": "password",
+      "type": "CONCEALED",
+      "purpose": "PASSWORD",
+      "label": "password",
+      "value": "PtZGFLAibx-erTo7ywywEvh-n4syas97n-tuF2D.b8DdqA2vCjrvRGkNQxj!Gi9R",
+      "entropy": 379.564697265625,
+      "reference": "op://Personal/Duplicate Sections/password",
+      "password_details": {
+        "entropy": 379,
+        "generated": true,
+        "strength": "FANTASTIC"
+      }
+    },
+    {
+      "id": "notesPlain",
+      "type": "STRING",
+      "purpose": "NOTES",
+      "label": "notesPlain",
+      "reference": "op://Personal/Duplicate Sections/notesPlain"
+    },
+    {
+      "id": "4saaazkb7arwisj6ysctb4jmm4",
+      "section": {
+        "id": "add more"
+      },
+      "type": "STRING",
+      "label": "text",
+      "value": "text field the first",
+      "reference": "op://Personal/Duplicate Sections/add more/text"
+    },
+    {
+      "id": "4vtfkj4bwcmg7d5uf62wnpkp3a",
+      "section": {
+        "id": "add more"
+      },
+      "type": "STRING",
+      "label": "text",
+      "value": "text field the second",
+      "reference": "op://Personal/Duplicate Sections/add more/text"
+    },
+    {
+      "id": "wbrjnowkrgavpooomtht36gjqu",
+      "section": {
+        "id": "7osqcvd43i75teocdzbb6d7mie",
+        "label": "Section 2"
+      },
+      "type": "STRING",
+      "label": "s2 text",
+      "value": "first value",
+      "reference": "op://Personal/Duplicate Sections/Section 2/s2 text"
+    },
+    {
+      "id": "bddlz2fj2pebmtfhksbmcexy7m",
+      "section": {
+        "id": "7osqcvd43i75teocdzbb6d7mie",
+        "label": "Section 2"
+      },
+      "type": "STRING",
+      "label": "s2 text",
+      "value": "second value",
+      "reference": "op://Personal/Duplicate Sections/Section 2/s2 text"
+    }
+  ]
+}
diff --git a/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json.license b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json.license
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/fixtures/v2_out_03.json.license
@@ -0,0 +1,3 @@
+GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+SPDX-License-Identifier: GPL-3.0-or-later
+SPDX-FileCopyrightText: 2022, Ansible Project
diff --git a/tests/unit/plugins/lookup/onepassword/test_onepassword.py b/tests/unit/plugins/lookup/onepassword/test_onepassword.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/test_onepassword.py
@@ -0,0 +1,182 @@
+# Copyright (c) 2020 Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import itertools
+import json
+import pytest
+
+from .conftest import OP_VERSION_FIXTURES
+from .common import MOCK_ENTRIES
+
+from ansible.errors import AnsibleLookupError
+from ansible.plugins.loader import lookup_loader
+from ansible_collections.community.general.plugins.lookup.onepassword import (
+    OnePassCLIv1,
+    OnePassCLIv2,
+)
+
+
+@pytest.mark.parametrize(
+    ("version", "version_class"),
+    (
+        ("1.17.2", OnePassCLIv1),
+        ("2.27.4", OnePassCLIv2),
+    )
+)
+def test_op_correct_cli_class(fake_op, version, version_class):
+    op = fake_op(version)
+    assert op._cli.version == version
+    assert isinstance(op._cli, version_class)
+
+
+def test_op_unsupported_cli_version(fake_op):
+    with pytest.raises(AnsibleLookupError, match="is unsupported"):
+        fake_op("99.77.77")
+
+
+@pytest.mark.parametrize("op_fixture", OP_VERSION_FIXTURES)
+def test_op_set_token_with_config(op_fixture, mocker, request):
+    op = request.getfixturevalue(op_fixture)
+    token = "F5417F77529B41B595D7F9D6F76EC057"
+    mocker.patch("os.path.isfile", return_value=True)
+    mocker.patch.object(op._cli, "signin", return_value=(0, token + "\n", ""))
+
+    op.set_token()
+
+    assert op.token == token
+
+
+@pytest.mark.parametrize(
+    ("op_fixture", "message"),
+    [
+        (op, value)
+        for op in OP_VERSION_FIXTURES
+        for value in
+        (
+            "Missing required parameters",
+            "The operation is unauthorized",
+        )
+    ]
+)
+def test_op_set_token_with_config_missing_args(op_fixture, message, request, mocker):
+    op = request.getfixturevalue(op_fixture)
+    mocker.patch("os.path.isfile", return_value=True)
+    mocker.patch.object(op._cli, "signin", return_value=(99, "", ""), side_effect=AnsibleLookupError(message))
+    mocker.patch.object(op._cli, "full_signin", return_value=(0, "", ""))
+
+    with pytest.raises(AnsibleLookupError, match=message):
+        op.set_token()
+
+    op._cli.full_signin.assert_not_called()
+
+
+@pytest.mark.parametrize("op_fixture", OP_VERSION_FIXTURES)
+def test_op_set_token_with_config_full_signin(op_fixture, request, mocker):
+    op = request.getfixturevalue(op_fixture)
+    mocker.patch("os.path.isfile", return_value=True)
+    mocker.patch.object(op._cli, "signin", return_value=(99, "", ""), side_effect=AnsibleLookupError("Raised intentionally"))
+    mocker.patch.object(op._cli, "full_signin", return_value=(0, "", ""))
+
+    op.set_token()
+
+    op._cli.full_signin.assert_called()
+
+
+@pytest.mark.parametrize("op_fixture", OP_VERSION_FIXTURES)
+def test_op_set_token_without_config(op_fixture, request, mocker):
+    op = request.getfixturevalue(op_fixture)
+    token = "B988E8A2680A4A348962751A96861FA1"
+    mocker.patch("os.path.isfile", return_value=False)
+    mocker.patch.object(op._cli, "signin", return_value=(99, "", ""))
+    mocker.patch.object(op._cli, "full_signin", return_value=(0, token + "\n", ""))
+
+    op.set_token()
+
+    op._cli.signin.assert_not_called()
+    assert op.token == token
+
+
+@pytest.mark.parametrize(
+    ("op_fixture", "login_status"),
+    [(op, value) for op in OP_VERSION_FIXTURES for value in [False, True]]
+)
+def test_op_assert_logged_in(mocker, login_status, op_fixture, request):
+    op = request.getfixturevalue(op_fixture)
+    mocker.patch.object(op._cli, "assert_logged_in", return_value=login_status)
+    mocker.patch.object(op, "set_token")
+
+    op.assert_logged_in()
+
+    op._cli.assert_logged_in.assert_called_once()
+    assert op.logged_in == login_status
+
+    if not login_status:
+        op.set_token.assert_called_once()
+
+
+@pytest.mark.parametrize("op_fixture", OP_VERSION_FIXTURES)
+def test_op_get_raw_v1(mocker, op_fixture, request):
+    op = request.getfixturevalue(op_fixture)
+    mocker.patch.object(op._cli, "get_raw", return_value=[99, "RAW OUTPUT", ""])
+
+    result = op.get_raw("some item")
+
+    assert result == "RAW OUTPUT"
+    op._cli.get_raw.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ("op_fixture", "output", "expected"),
+    (
+        list(itertools.chain([op], d))
+        for op in OP_VERSION_FIXTURES
+        for d in [
+            ("RAW OUTPUT", "RAW OUTPUT"),
+            (None, ""),
+            ("", ""),
+        ]
+    )
+)
+def test_op_get_field(mocker, op_fixture, output, expected, request):
+    op = request.getfixturevalue(op_fixture)
+    mocker.patch.object(op, "get_raw", return_value=output)
+    mocker.patch.object(op._cli, "_parse_field", return_value=output)
+
+    result = op.get_field("some item", "some field")
+
+    assert result == expected
+
+
+# This test sometimes fails on older Python versions because the gathered tests mismatch.
+# Sort the fixture data to make this reliable
+# https://github.com/pytest-dev/pytest-xdist/issues/432
+@pytest.mark.parametrize(
+    ("cli_class", "vault", "queries", "kwargs", "output", "expected"),
+    (
+        (_cli_class, item["vault_name"], item["queries"], item.get("kwargs", {}), item["output"], item["expected"])
+        for _cli_class in MOCK_ENTRIES
+        for item in MOCK_ENTRIES[_cli_class]
+    )
+)
+def test_op_lookup(mocker, cli_class, vault, queries, kwargs, output, expected):
+    mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePass._get_cli_class", cli_class)
+    mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePass.assert_logged_in", return_value=True)
+    mocker.patch("ansible_collections.community.general.plugins.lookup.onepassword.OnePassCLIBase._run", return_value=(0, json.dumps(output), ""))
+
+    op_lookup = lookup_loader.get("community.general.onepassword")
+    result = op_lookup.run(queries, vault=vault, **kwargs)
+
+    assert result == expected
+
+
+@pytest.mark.parametrize("op_fixture", OP_VERSION_FIXTURES)
+def test_signin(op_fixture, request):
+    op = request.getfixturevalue(op_fixture)
+    op._cli.master_password = "master_pass"
+    op._cli.signin()
+    print(op._cli.version)
+    op._cli._run.assert_called_once_with(['signin', '--raw'], command_input=b"master_pass")
diff --git a/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v1.py b/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v1.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v1.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2022 Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+
+from ansible_collections.community.general.plugins.lookup.onepassword import OnePassCLIv1
+
+
+@pytest.mark.parametrize(
+    ("args", "rc", "expected_call_args", "expected_call_kwargs", "expected"),
+    (
+        ([], 0, ["get", "account"], {"ignore_errors": True}, True,),
+        ([], 1, ["get", "account"], {"ignore_errors": True}, False,),
+        (["acme"], 1, ["get", "account", "--account", "acme.1password.com"], {"ignore_errors": True}, False,),
+    )
+)
+def test_assert_logged_in(mocker, args, rc, expected_call_args, expected_call_kwargs, expected):
+    mocker.patch.object(OnePassCLIv1, "_run", return_value=[rc, "", ""])
+
+    op_cli = OnePassCLIv1(*args)
+    result = op_cli.assert_logged_in()
+
+    op_cli._run.assert_called_with(expected_call_args, **expected_call_kwargs)
+    assert result == expected
+
+
+def test_full_signin(mocker):
+    mocker.patch.object(OnePassCLIv1, "_run", return_value=[0, "", ""])
+
+    op_cli = OnePassCLIv1(
+        subdomain="acme",
+        username="bob@acme.com",
+        secret_key="SECRET",
+        master_password="ONEKEYTORULETHEMALL",
+    )
+    result = op_cli.full_signin()
+
+    op_cli._run.assert_called_with([
+        "signin",
+        "acme.1password.com",
+        b"bob@acme.com",
+        b"SECRET",
+        "--raw",
+    ], command_input=b"ONEKEYTORULETHEMALL")
+    assert result == [0, "", ""]
diff --git a/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v2.py b/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v2.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/lookup/onepassword/test_onepassword_cli_v2.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2022 Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import pytest
+
+
+from ansible_collections.community.general.plugins.lookup.onepassword import OnePassCLIv2
+
+
+@pytest.mark.parametrize(
+    ("args", "out", "expected_call_args", "expected"),
+    (
+        ([], "list of accounts", ["account", "get"], True,),
+        (["acme"], "list of accounts", ["account", "get", "--account", "acme.1password.com"], True,),
+        ([], "", ["account", "list"], False,),
+    )
+)
+def test_assert_logged_in(mocker, args, out, expected_call_args, expected):
+    mocker.patch.object(OnePassCLIv2, "_run", return_value=[0, out, ""])
+    op_cli = OnePassCLIv2(*args)
+    result = op_cli.assert_logged_in()
+
+    op_cli._run.assert_called_with(expected_call_args)
+    assert result == expected
+
+
+def test_full_signin(mocker):
+    mocker.patch.object(OnePassCLIv2, "_run", return_value=[0, "", ""])
+
+    op_cli = OnePassCLIv2(
+        subdomain="acme",
+        username="bob@acme.com",
+        secret_key="SECRET",
+        master_password="ONEKEYTORULETHEMALL",
+    )
+    result = op_cli.full_signin()
+
+    op_cli._run.assert_called_with(
+        [
+            "account", "add", "--raw",
+            "--address", "acme.1password.com",
+            "--email", b"bob@acme.com",
+            "--signin",
+        ],
+        command_input=b"ONEKEYTORULETHEMALL",
+        environment_update={'OP_SECRET_KEY': 'SECRET'},
+    )
+    assert result == [0, "", ""]
diff --git a/tests/unit/plugins/lookup/test_onepassword.py b/tests/unit/plugins/lookup/test_onepassword.py
deleted file mode 100644
--- a/tests/unit/plugins/lookup/test_onepassword.py
+++ /dev/null
@@ -1,322 +0,0 @@
-# Copyright (c) 2018, Scott Buchanan <sbuchanan@ri.pn>
-# Copyright (c) 2016, Andrew Zenk <azenk@umn.edu> (test_lastpass.py used as starting point)
-# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
-# SPDX-License-Identifier: GPL-3.0-or-later
-from __future__ import (absolute_import, division, print_function)
-__metaclass__ = type
-
-import json
-import datetime
-
-try:
-    from urllib.parse import urlparse
-except ImportError:
-    from urlparse import urlparse
-
-from argparse import ArgumentParser
-
-
-from ansible_collections.community.general.tests.unit.compat import unittest
-from ansible_collections.community.general.tests.unit.compat.mock import patch
-from ansible.errors import AnsibleError
-from ansible_collections.community.general.plugins.lookup.onepassword import OnePass, LookupModule
-from ansible_collections.community.general.plugins.lookup.onepassword_raw import LookupModule as OnePasswordRawLookup
-
-
-# Intentionally excludes metadata leaf nodes that would exist in real output if not relevant.
-MOCK_ENTRIES = [
-    {
-        'vault_name': 'Acme "Quot\'d" Servers',
-        'queries': [
-            '0123456789',
-            'Mock "Quot\'d" Server'
-        ],
-        'output': {
-            'uuid': '0123456789',
-            'vaultUuid': '2468',
-            'overview': {
-                'title': 'Mock "Quot\'d" Server'
-            },
-            'details': {
-                'sections': [{
-                    'title': '',
-                    'fields': [
-                        {'t': 'username', 'v': 'jamesbond'},
-                        {'t': 'password', 'v': 't0pS3cret'},
-                        {'t': 'notes', 'v': 'Test note with\nmultiple lines and trailing space.\n\n'},
-                        {'t': 'tricksy "quot\'d" field\\', 'v': '"quot\'d" value'}
-                    ]
-                }]
-            }
-        }
-    },
-    {
-        'vault_name': 'Acme Logins',
-        'queries': [
-            '9876543210',
-            'Mock Website',
-            'acme.com'
-        ],
-        'output': {
-            'uuid': '9876543210',
-            'vaultUuid': '1357',
-            'overview': {
-                'title': 'Mock Website',
-                'URLs': [
-                    {'l': 'website', 'u': 'https://acme.com/login'}
-                ]
-            },
-            'details': {
-                'sections': [{
-                    'title': '',
-                    'fields': [
-                        {'t': 'password', 'v': 't0pS3cret'}
-                    ]
-                }]
-            }
-        }
-    },
-    {
-        'vault_name': 'Acme Logins',
-        'queries': [
-            '864201357'
-        ],
-        'output': {
-            'uuid': '864201357',
-            'vaultUuid': '1357',
-            'overview': {
-                'title': 'Mock Something'
-            },
-            'details': {
-                'fields': [
-                    {
-                        'value': 'jbond@mi6.gov.uk',
-                        'name': 'emailAddress'
-                    },
-                    {
-                        'name': 'password',
-                        'value': 'vauxhall'
-                    },
-                    {},
-                ]
-            }
-        }
-    },
-]
-
-
-def get_mock_query_generator(require_field=None):
-    def _process_field(field, section_title=None):
-        field_name = field.get('name', field.get('t', ''))
-        field_value = field.get('value', field.get('v', ''))
-
-        if require_field is None or field_name == require_field:
-            return entry, query, section_title, field_name, field_value
-
-    for entry in MOCK_ENTRIES:
-        for query in entry['queries']:
-            for field in entry['output']['details'].get('fields', []):
-                fixture = _process_field(field)
-                if fixture:
-                    yield fixture
-            for section in entry['output']['details'].get('sections', []):
-                for field in section['fields']:
-                    fixture = _process_field(field, section['title'])
-                    if fixture:
-                        yield fixture
-
-
-def get_one_mock_query(require_field=None):
-    generator = get_mock_query_generator(require_field)
-    return next(generator)
-
-
-class MockOnePass(OnePass):
-
-    _mock_logged_out = False
-    _mock_timed_out = False
-
-    def _lookup_mock_entry(self, key, vault=None):
-        for entry in MOCK_ENTRIES:
-            if vault is not None and vault.lower() != entry['vault_name'].lower() and vault.lower() != entry['output']['vaultUuid'].lower():
-                continue
-
-            match_fields = [
-                entry['output']['uuid'],
-                entry['output']['overview']['title']
-            ]
-
-            # Note that exactly how 1Password matches on domains in non-trivial cases is neither documented
-            # nor obvious, so this may not precisely match the real behavior.
-            urls = entry['output']['overview'].get('URLs')
-            if urls is not None:
-                match_fields += [urlparse(url['u']).netloc for url in urls]
-
-            if key in match_fields:
-                return entry['output']
-
-    def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False):
-        parser = ArgumentParser()
-
-        command_parser = parser.add_subparsers(dest='command')
-
-        get_parser = command_parser.add_parser('get')
-        get_options = ArgumentParser(add_help=False)
-        get_options.add_argument('--vault')
-        get_type_parser = get_parser.add_subparsers(dest='object_type')
-        get_type_parser.add_parser('account', parents=[get_options])
-        get_item_parser = get_type_parser.add_parser('item', parents=[get_options])
-        get_item_parser.add_argument('item_id')
-
-        args = parser.parse_args(args)
-
-        def mock_exit(output='', error='', rc=0):
-            if rc != expected_rc:
-                raise AnsibleError(error)
-            if error != '':
-                now = datetime.date.today()
-                error = '[LOG] {0} (ERROR) {1}'.format(now.strftime('%Y/%m/%d %H:$M:$S'), error)
-            return rc, output, error
-
-        if args.command == 'get':
-            if self._mock_logged_out:
-                return mock_exit(error='You are not currently signed in. Please run `op signin --help` for instructions', rc=1)
-
-            if self._mock_timed_out:
-                return mock_exit(error='401: Authentication required.', rc=1)
-
-            if args.object_type == 'item':
-                mock_entry = self._lookup_mock_entry(args.item_id, args.vault)
-
-                if mock_entry is None:
-                    return mock_exit(error='Item {0} not found'.format(args.item_id))
-
-                return mock_exit(output=json.dumps(mock_entry))
-
-            if args.object_type == 'account':
-                # Since we don't actually ever use this output, don't bother mocking output.
-                return mock_exit()
-
-        raise AnsibleError('Unsupported command string passed to OnePass mock: {0}'.format(args))
-
-
-class LoggedOutMockOnePass(MockOnePass):
-
-    _mock_logged_out = True
-
-
-class TimedOutMockOnePass(MockOnePass):
-
-    _mock_timed_out = True
-
-
-class TestOnePass(unittest.TestCase):
-
-    def test_onepassword_cli_path(self):
-        op = MockOnePass(path='/dev/null')
-        self.assertEqual('/dev/null', op.cli_path)
-
-    def test_onepassword_logged_in(self):
-        op = MockOnePass()
-        try:
-            op.assert_logged_in()
-        except Exception:
-            self.fail()
-
-    def test_onepassword_logged_out(self):
-        op = LoggedOutMockOnePass()
-        with self.assertRaises(AnsibleError):
-            op.assert_logged_in()
-
-    def test_onepassword_timed_out(self):
-        op = TimedOutMockOnePass()
-        with self.assertRaises(AnsibleError):
-            op.assert_logged_in()
-
-    def test_onepassword_get(self):
-        op = MockOnePass()
-        op.logged_in = True
-        query_generator = get_mock_query_generator()
-        for dummy, query, dummy, field_name, field_value in query_generator:
-            self.assertEqual(field_value, op.get_field(query, field_name))
-
-    def test_onepassword_get_raw(self):
-        op = MockOnePass()
-        op.logged_in = True
-        for entry in MOCK_ENTRIES:
-            for query in entry['queries']:
-                self.assertEqual(json.dumps(entry['output']), op.get_raw(query))
-
-    def test_onepassword_get_not_found(self):
-        op = MockOnePass()
-        op.logged_in = True
-        self.assertEqual('', op.get_field('a fake query', 'a fake field'))
-
-    def test_onepassword_get_with_section(self):
-        op = MockOnePass()
-        op.logged_in = True
-        dummy, query, section_title, field_name, field_value = get_one_mock_query()
-        self.assertEqual(field_value, op.get_field(query, field_name, section=section_title))
-
-    def test_onepassword_get_with_vault(self):
-        op = MockOnePass()
-        op.logged_in = True
-        entry, query, dummy, field_name, field_value = get_one_mock_query()
-        for vault_query in [entry['vault_name'], entry['output']['vaultUuid']]:
-            self.assertEqual(field_value, op.get_field(query, field_name, vault=vault_query))
-
-    def test_onepassword_get_with_wrong_vault(self):
-        op = MockOnePass()
-        op.logged_in = True
-        dummy, query, dummy, field_name, dummy = get_one_mock_query()
-        self.assertEqual('', op.get_field(query, field_name, vault='a fake vault'))
-
-    def test_onepassword_get_diff_case(self):
-        op = MockOnePass()
-        op.logged_in = True
-        entry, query, section_title, field_name, field_value = get_one_mock_query()
-        self.assertEqual(
-            field_value,
-            op.get_field(
-                query,
-                field_name.upper(),
-                vault=entry['vault_name'].upper(),
-                section=section_title.upper()
-            )
-        )
-
-
-@patch('ansible_collections.community.general.plugins.lookup.onepassword.OnePass', MockOnePass)
-class TestLookupModule(unittest.TestCase):
-
-    def test_onepassword_plugin_multiple(self):
-        lookup_plugin = LookupModule()
-
-        entry = MOCK_ENTRIES[0]
-        field = entry['output']['details']['sections'][0]['fields'][0]
-
-        self.assertEqual(
-            [field['v']] * len(entry['queries']),
-            lookup_plugin.run(entry['queries'], field=field['t'])
-        )
-
-    def test_onepassword_plugin_default_field(self):
-        lookup_plugin = LookupModule()
-
-        dummy, query, dummy, dummy, field_value = get_one_mock_query('password')
-        self.assertEqual([field_value], lookup_plugin.run([query]))
-
-
-@patch('ansible_collections.community.general.plugins.lookup.onepassword_raw.OnePass', MockOnePass)
-class TestOnePasswordRawLookup(unittest.TestCase):
-
-    def test_onepassword_raw_plugin_multiple(self):
-        raw_lookup_plugin = OnePasswordRawLookup()
-
-        entry = MOCK_ENTRIES[0]
-        raw_value = entry['output']
-
-        self.assertEqual(
-            [raw_value] * len(entry['queries']),
-            raw_lookup_plugin.run(entry['queries'])
-        )
diff --git a/tests/unit/plugins/module_utils/test_onepassword.py b/tests/unit/plugins/module_utils/test_onepassword.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/module_utils/test_onepassword.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2022 Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+import pytest
+
+from ansible_collections.community.general.plugins.module_utils.onepassword import OnePasswordConfig
+
+
+@pytest.fixture
+def os_expanduser(mocker):
+    def _os_expanduser(path):
+        return path.replace("~", "/home/testuser")
+
+    mocker.patch("os.path.expanduser", side_effect=_os_expanduser)
+
+
+@pytest.fixture
+def exists(mocker):
+    def _exists(path):
+        if "op/" in path:
+            return True
+
+        return os.path.exists(path)
+
+
+def test_op_config(mocker, os_expanduser):
+    mocker.patch("os.path.exists", side_effect=[False, True])
+    op_config = OnePasswordConfig()
+
+    assert "/home/testuser/.config/op/config" == op_config.config_file_path
+
+
+def test_op_no_config(mocker, os_expanduser):
+    mocker.patch("os.path.exists", return_value=False)
+    op_config = OnePasswordConfig()
+
+    assert op_config.config_file_path is None