From 06349b6794001a5e5197fb652183655e27998d16 Mon Sep 17 00:00:00 2001
From: Felix Tomski <tomski@itc.rwth-aachen.de>
Date: Wed, 8 Nov 2023 08:32:50 +0100
Subject: [PATCH] Rework accountmanager

---
 .gitlab-ci.yml                           |   4 +
 AccountManager.py                        | 267 +++++++++++++++++++++++
 Installer.py                             |  28 +--
 JSONAccountManager.py                    | 249 ---------------------
 core/authentication/EncryptionManager.py |   2 -
 core/utility/cli.py                      |  22 ++
 test/__init__.py                         |   0
 test/account_manager/__init__.py         |   0
 test/account_manager/test_manager.py     |  78 +++++++
 utility/.gitlab/.unittest.yml            |  18 ++
 10 files changed, 392 insertions(+), 276 deletions(-)
 create mode 100755 AccountManager.py
 delete mode 100644 JSONAccountManager.py
 create mode 100644 core/utility/cli.py
 create mode 100644 test/__init__.py
 create mode 100644 test/account_manager/__init__.py
 create mode 100644 test/account_manager/test_manager.py
 create mode 100644 utility/.gitlab/.unittest.yml

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f8d65bd..20626ef 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,9 +14,13 @@
 # For more information, see: https://docs.gitlab.com/ee/ci/yaml/index.html#stages
 
 stages:          # List of stages for jobs, and their order of execution
+  - unittest
   - run
   - deploy
 
+include:
+  - local: 'utility/.gitlab/.unittest.yml'
+
 variables:
   CI_LOG_STDOUT: "1"
 
diff --git a/AccountManager.py b/AccountManager.py
new file mode 100755
index 0000000..b799612
--- /dev/null
+++ b/AccountManager.py
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+import argparse
+import string
+import random
+import os
+import sys
+import logging
+from pprint import pprint
+
+import core.authentication.EncryptionManager as encrypt
+import core.utility.cli as cli
+
+# Define Commandline interface
+def CLI(_args):
+    mod_common_pars = [
+        {
+            "flags": ["-uid", "--gitlab-user-id"],
+            "type": str,
+            "metavar": "<uid>",
+            "help": "The gitlab uid of the assigned user."
+        },
+        {
+            "flags": ["-pid", "--gitlab-project-id"],
+            "type": str,
+            "metavar": "<pid>",
+            "help": "The gitlab pid of the assigned project."
+        },
+        {
+            "flags": ['-url', '--gitlab-url'],
+            "type": str,
+            "metavar": "<pub>",
+            "required": True,
+            "help": "Path to the public key file."
+        },
+    ]
+
+    subcommands = {
+        'init': {
+            'aliases': ['i'],
+            'f': init_mapping
+        },
+        'remove': {
+            'aliases': ['rm'],
+            'f': remove
+        },
+        'add': {
+            'aliases': ['a'],
+            'f': add
+        },
+        'print': {
+            'aliases': ['p'],
+            'f': print_mapping
+        },
+    }
+
+    parameters = {
+    'init': [
+        {
+            "flags": ['-pub', '--pub-key-file'],
+            "type": str,
+            "metavar": "<file>",
+            "default": "key.pub",
+            "help": "Path to the public key file.",
+        },
+    ],
+    'remove': [        
+        *mod_common_pars,
+    ],
+    'add': [
+        {
+            "flags": ["-del", "--delete-date"],
+            "type": str,
+            "metavar": "<del>",
+            "default": "never",
+            "help": "The deletion date of the coupling. Default: never"
+        },
+        {
+            "flags": ["-acc", "--cluster-account-name"],
+            "type": str,
+            "metavar": "<acc>",
+            "required": True,
+            "help": "Name of the assigned cluster account."
+        },
+        *mod_common_pars,
+    ],
+    'print': [],
+    'global': [
+        {
+            "flags": ["-priv", "--priv-key-file"],
+            "type": str,
+            "metavar": "<file>",
+            "default": "key",
+            "help": "Path to the private key file."
+        },
+        {
+            "flags": ["-aes", "--aes-key-file"],
+            "type": str,
+            "metavar": "<file>",
+            "default": "aes.txt",
+            "help": "Path to the AES key."
+        },
+        {
+            "flags": ["-map", "--mapping-file"],
+            "type": str,
+            "metavar": "<file>",
+            "default": "mapping.txt",
+            "help": "Path to the account mapping file."
+        },
+        {
+            "flags": ["-bp", "--base-path"],
+            "type": str,
+            "metavar": "<path>",
+            "default": "./",
+            "help": "Interpret keys, mapping etc. relative to this path. Default: cwd"
+        },
+        {
+            "flags": ["-lf", "--log-file"],
+            "type": str,
+            "metavar": "<file>",
+            "default": "history.log",
+            "help": "Log history in this file. Default: history.log"
+        },
+        {
+            "flags": ["-ll", "--log-level"],
+            "type": str,
+            "metavar": "<DEBUG|INFO|WARNING|ERROR|CRITICAL>",
+            "default": "INFO",
+                "help": "Log level. Default: INFO"
+        },
+    ]
+    }
+
+    parser = argparse.ArgumentParser(prog='Aixcellenz CI AccountManager')
+    cli.add_parameters(parser, parameters['global'])
+    subparsers = parser.add_subparsers(help='sub-command help')
+
+    for cmd in subcommands:
+        subcmd_parser = subparsers.add_parser(cmd, help=f'{cmd} help', aliases=subcommands[cmd]['aliases'])
+        subcmd_parser.set_defaults(func=subcommands[cmd]['f'])
+        cli.add_parameters(subcmd_parser, parameters[cmd])
+
+    ret = parser.parse_args(_args)
+    ret.base_path = os.path.abspath(os.path.expandvars(os.path.expanduser(ret.base_path)))
+    os.makedirs(ret.base_path, exist_ok=True)
+    args_to_expand = ['mapping_file', 'priv_key_file', 'pub_key_file', 'aes_key_file',
+                      'log_file']
+    for arg in args_to_expand:
+        if arg in ret:
+            tmp = os.path.expandvars(os.path.expanduser(vars(ret)[arg]))
+            if not os.path.isabs(tmp):
+                vars(ret)[arg] = os.path.join(ret.base_path, tmp)
+
+    return ret
+
+
+def get_random_string(length: int) -> str:
+    # choose from all lowercase letter
+    letters = string.ascii_letters + string.digits
+    result_str = ''.join(random.choice(letters) for _ in range(length))
+    return result_str
+
+def _init_mapping(priv_key_file, pub_key_file, mapping_file, aes_key_file):
+    if not (os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file)):
+        encrypt.create_keys(priv_key_file, pub_key_file)
+    with open(mapping_file, "w") as text_file:
+        text_file.write('')
+    if not os.path.isfile(aes_key_file):
+        encrypt.set_AES_key(get_random_string(16), aes_key_file, pub_key_file)
+    logger.info(f'Added initial mapping at {mapping_file} with aes_key at {aes_key_file}')
+
+def init_mapping(args):
+    #pprint(args)
+    if os.path.isfile(args.mapping_file):
+        logger.error(f'Mapping at {args.mapping_file} already exists. (aborting)')
+        sys.exit(1)
+    _init_mapping(args.priv_key_file, args.pub_key_file,
+                  args.mapping_file, args.aes_key_file)
+
+
+def _add_id(url, id, priv_key_file, mapping_file, cluster_acc, delete_date, aes_key_file, id_type='uid'):
+    mapping = encrypt.read_mapping(priv_key_file, mapping_file, aes_key_file)
+    if url not in mapping:
+        mapping[url] = {"uid": {}, "pid": {}}
+    if id in mapping[url][id_type]:
+        logger.error(f"Mapping for project={id} at gitlab instance={url} already present (aborting)")
+        sys.exit(1)
+    id_dict = {"acc": cluster_acc, "delete": delete_date}
+    mapping[url][id_type][id] = id_dict
+    encrypt.write_mapping(mapping, priv_key_file, mapping_file, aes_key_file)
+    logger.info(f'Added CI access for url={url}, id_type={id_type}, id={id}, acc={cluster_acc}, delete={delete_date} at mapping={mapping_file}')
+
+def add(args):
+    if args.gitlab_user_id:
+        _add_id(args.gitlab_url, args.gitlab_user_id, args.priv_key_file, args.mapping_file,
+                args.cluster_account_name, args.delete_date, args.aes_key_file, 'uid')
+    if args.gitlab_project_id:
+        _add_id(args.gitlab_url, args.gitlab_project_id, args.priv_key_file, args.mapping_file,
+                args.cluster_account_name, args.delete_date, args.aes_key_file, 'pid')
+    if not args.gitlab_user_id and not args.gitlab_project_id:
+        logger.debug(f'Could not add due to missing user or project id')
+
+
+def _remove_id(url, id, priv_key_file, mapping_file, aes_key_file, id_type='uid'):
+    try:
+        mapping = encrypt.read_mapping(priv_key_file, mapping_file, aes_key_file)
+        cluster_account = mapping[url][id_type][id]["acc"]
+        del mapping[url][id_type][id]
+        encrypt.write_mapping(mapping, priv_key_file, mapping_file, aes_key_file)
+        logger.info(f"Removed CI access for cluster account={cluster_account}")
+    except KeyError as e:
+        logger.error(f'Could not remove id={id}({id_type}) from mapping={mapping_file}. (aborting)')
+        sys.exit(1)
+
+def _remove_url(url, priv_key_file, mapping_file, aes_key_file):
+    try:
+        mapping = encrypt.read_mapping(priv_key_file, mapping_file, aes_key_file)
+        del mapping[url]
+        encrypt.write_mapping(mapping, priv_key_file, mapping_file, aes_key_file)
+        logger.info(f"Removed CI access for url={url}")
+    except KeyError as e:
+        logger.error(f'Could not remove url={url} from mapping={mapping_file} (aborting)')
+        sys.exit(1)
+
+def remove(args):
+    if args.gitlab_user_id:
+        _remove_id(args.gitlab_url, args.gitlab_user_id, args.priv_key_file, args.mapping_file,
+                   args.aes_key_file, 'uid')
+    if args.gitlab_project_id:
+        _remove_id(args.gitlab_url, args.gitlab_project_id, args.priv_key_file, args.mapping_file,
+                   args.aes_key_file, 'pid')
+    if not args.gitlab_user_id and not args.gitlab_project_id:
+        _remove_url(args.gitlab_url, args.priv_key_file, args.mapping_file, args.aes_key_file)
+
+def _get_mapping(args):
+    return encrypt.read_mapping(args.priv_key_file, args.mapping_file, args.aes_key_file)
+
+def _id_present(args, id, id_type='uid'):
+    try:
+        encrypt.read_mapping(args.priv_key_file, args.mapping_file, args.aes_key_file)[args.gitlab_url][id_type][id]
+        return True
+    except:
+        return False
+
+
+def print_mapping(args):
+    pprint(_get_mapping(args))
+
+def _setup_logging(level, filename, std=True):
+    global logger
+    logger = logging.getLogger(__file__)
+    logger.setLevel(level)
+    file_handler = logging.FileHandler(filename, encoding='utf-8')
+
+    formatter = logging.Formatter('[%(asctime)s] %(levelname)s:%(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+    file_handler.setFormatter(formatter)
+    file_handler.setLevel(level)
+    if std:
+        std_handler = logging.StreamHandler()
+        std_handler.setLevel(level)
+        logger.addHandler(std_handler)
+    logger.addHandler(file_handler)
+
+
+if __name__ == '__main__':
+    args = CLI(sys.argv[1:])
+    _setup_logging(getattr(logging, args.log_level.upper(), 20), args.log_file)
+    args.func(args)
\ No newline at end of file
diff --git a/Installer.py b/Installer.py
index 930aae3..ca3b66e 100755
--- a/Installer.py
+++ b/Installer.py
@@ -4,29 +4,7 @@ import os
 import sys
 import subprocess
 
-
-def add_parameters(parser, param_list):
-    for p in param_list:
-        if p.get("action"):
-            parser.add_argument(
-                *p.get("flags"),
-                required=p.get("required"),
-                default=p.get("default"),
-                action=p.get("action"),
-                help=p.get("help")
-            )
-        else:
-            parser.add_argument(
-                *p.get("flags"),
-                required=p.get("required"),
-                type=p.get("type"),
-                choices=p.get("choices"),
-                nargs=p.get("nargs"),
-                default=p.get("default"),
-                const=p.get("const"),
-                metavar=p.get("metavar"),
-                help=p.get("help")
-            )
+import core.utility.cli as cli
 
 
 # Define Commandline interface
@@ -185,13 +163,13 @@ def CLI():
     }
 
     parser = argparse.ArgumentParser(prog='Aixcellenz CI Driver Installer')
-    add_parameters(parser, parameters['global'])
+    cli.add_parameters(parser, parameters['global'])
     subparsers = parser.add_subparsers(dest='cmd_name', help='sub-command help')
 
     for cmd in subcommands:
         subcmd_parser = subparsers.add_parser(cmd, help=f'{cmd} help', aliases=subcommands[cmd]['aliases'])
         subcmd_parser.set_defaults(func=subcommands[cmd]['f'])
-        add_parameters(subcmd_parser, parameters[cmd])
+        cli.add_parameters(subcmd_parser, parameters[cmd])
 
     ret = parser.parse_args()
     ret.install_path = os.path.abspath(os.path.expandvars(os.path.expanduser(ret.install_path)))
diff --git a/JSONAccountManager.py b/JSONAccountManager.py
deleted file mode 100644
index 6b47c42..0000000
--- a/JSONAccountManager.py
+++ /dev/null
@@ -1,249 +0,0 @@
-import argparse
-import os.path
-import string
-import random
-import os
-import sys
-
-import core.authentication.EncryptionManager as encrypt
-
-# Define Commandline interface
-def CLI():
-    parameters = [
-        {
-            "flags": ['-c', '--create'],
-            "action": "store_true",
-            "help": "Create a key pair and a mapping file."
-        },
-        {
-            "flags": ['-rm', '--remove-mapping'],
-            "action": "store_true",
-            "help": "Remove a uid/pid from the mapping file."
-        },
-        {
-            "flags": ['-rmu', '--remove-url'],
-            "action": "store_true",
-            "help": "Remove a url from the mapping file."
-        },
-        {
-            "flags": ['-add', '--add-mapping'],
-            "action": "store_true",
-            "help": "Remove a uid/pid and cluster account to the mapping file."
-        },
-        {
-            "flags": ['-url', '--gitlab-url'],
-            "type": str,
-            "metavar": "<pub>",
-            "help": "Path to the public key file."
-        },
-        {
-            "flags": ['-pub', '--public-key-path'],
-            "type": str,
-            "metavar": "<pub>",
-            "help": "Path to the public key file."
-        },
-        {
-            "flags": ["-priv", "--private-key-path"],
-            "type": str,
-            "metavar": "<priv>",
-            "help": "Path to the private key file."
-        },
-        {
-            "flags": ["-map", "--mapping-path"],
-            "type": str,
-            "metavar": "<mapping>",
-            "help": "Path to the account mapping file."
-        },
-        {
-            "flags": ["-acc", "--cluster-account-name"],
-            "type": str,
-            "metavar": "<acc>",
-            "help": "Name of the assigned cluster account."
-        },
-        {
-            "flags": ["-uid", "--gitlab-user-id"],
-            "type": str,
-            "metavar": "<uid>",
-            "help": "The gitlab uid of the assigned user."
-        },
-        {
-            "flags": ["-pid", "--gitlab-project-id"],
-            "type": str,
-            "metavar": "<pid>",
-            "help": "The gitlab pid of the assigned project."
-        },
-        {
-            "flags": ["-del", "--delete-date"],
-            "type": str,
-            "metavar": "<del>",
-            "help": "The deletion date of the coupling."
-        },
-        {
-            "flags": ["-aes", "--aes-encryption-key-path"],
-            "type": str,
-            "metavar": "<aes>",
-            "help": "Path to the AES key."
-        },
-    ]
-
-    parser = argparse.ArgumentParser()
-
-    for p in parameters:
-        if p.get("action"):
-            parser.add_argument(
-                *p.get("flags"),
-                required=p.get("required"),
-                default=p.get("default"),
-                action=p.get("action"),
-                help=p.get("help")
-            )
-        else:
-            parser.add_argument(
-                *p.get("flags"),
-                required=p.get("required"),
-                type=p.get("type"),
-                choices=p.get("choices"),
-                nargs=p.get("nargs"),
-                default=p.get("default"),
-                const=p.get("const"),
-                metavar=p.get("metavar"),
-                help=p.get("help")
-            )
-
-    return parser.parse_args()
-
-
-def get_random_string(length: int) -> str:
-    # choose from all lowercase letter
-    letters = string.ascii_letters + string.digits
-    result_str = ''.join(random.choice(letters) for i in range(length))
-    return result_str
-
-def create_mapping(priv_key_path, pub_key_path, map_path, AES_key):
-    encrypt.create_keys(priv_key_path, pub_key_path)
-    with open(map_path, "w") as text_file:
-        text_file.write('')
-    string = get_random_string(16)
-    encrypt.set_AES_key(string, AES_key, pub_key_path)
-
-
-def add_user_account(url, uid, priv_key_path, pub_key_path, map_path, cluster_acc, delete_date, AES_key):
-    mapping = encrypt.read_mapping(priv_key_path, map_path, AES_key)
-    if url not in mapping:
-        mapping[url] = {"uid": {}, "pid": {}}
-    if uid in mapping[url]["uid"]:
-        print(f"Mapping for user: {uid} at gitlab instance: {url} already present.")
-        sys.exit(1)
-    uid_dict = {"acc": cluster_acc, "delete": delete_date}
-    mapping[url]["uid"][uid] = uid_dict
-    encrypt.write_mapping(mapping, priv_key_path, map_path, AES_key)
-
-
-def add_project_account(url, pid, priv_key_path, pub_key_path, map_path, cluster_acc, delete_date, AES_key):
-    mapping = encrypt.read_mapping(priv_key_path, map_path, AES_key)
-    if url not in mapping:
-        mapping[url] = {"uid": {}, "pid": {}}
-    if pid in mapping[url]["pid"]:
-        print(f"Mapping for project: {pid} at gitlab instance: {url} already present.")
-        sys.exit(1)
-    pid_dict = {"acc": cluster_acc, "delete": delete_date}
-    mapping[url]["pid"][pid] = pid_dict
-    encrypt.write_mapping(mapping, priv_key_path, map_path, AES_key)
-
-
-def remove_user_account(url, uid, priv_key_path, pub_key_path, map_path, AES_key):
-    mapping = encrypt.read_mapping(priv_key_path, map_path, AES_key)
-    cluster_account = mapping[url]["uid"][uid]["acc"]
-    del mapping[url]["uid"][uid]
-    encrypt.write_mapping(mapping, priv_key_path, map_path, AES_key)
-    print(f"Removed CI access for cluster account: {cluster_account}")
-
-
-def remove_project_account(url, pid, priv_key_path, pub_key_path, map_path, AES_key):
-    mapping = encrypt.read_mapping(priv_key_path, map_path, AES_key)
-    cluster_account = mapping[url]["pid"][pid]["acc"]
-    del mapping[url]["pid"][pid]
-    encrypt.write_mapping(mapping, priv_key_path, map_path, AES_key)
-    print(f"Removed CI access for cluster account: {cluster_account}")
-
-
-def remove_url(url, priv_key_path, pub_key_path, map_path, AES_key):
-    mapping = encrypt.read_mapping(priv_key_path, map_path, AES_key)
-    removed_serv_accounts = []
-    for x in mapping[url]["pid"]:
-        removed_serv_accounts.append(mapping[url]["pid"][x]["acc"])
-    for x in mapping[url]["uid"]:
-        removed_serv_accounts.append(mapping[url]["uid"][x]["acc"])
-    del mapping[url]
-    encrypt.write_mapping(mapping, priv_key_path, map_path, AES_key)
-    print(f"Removed CI access for cluster accounts: {removed_serv_accounts}")
-
-
-def run():
-    args = CLI()
-
-    if args.remove_mapping and args.add_mapping and args.remove_url:
-        print("Remove(-url) and add cannot be used in the same call.")
-        sys.exit(1)
-
-    if args.gitlab_user_id is not None and args.gitlab_project_id is not None:
-        print("Gitlab project id and gitlab user id cannot be provided in the same call.")
-        sys.exit(1)
-
-    if args.create:
-        if args.private_key_path is None or args.public_key_path is None or args.mapping_path is None or args.aes_encryption_key_path is None:
-            print("Arguments for private/public key and mapping path must be provided.")
-            sys.exit(1)
-        else:
-            create_mapping(os.path.abspath(args.private_key_path), os.path.abspath(args.public_key_path),
-                           os.path.abspath(args.mapping_path), os.path.abspath(args.aes_encryption_key_path))
-
-    if args.remove_mapping:
-        if args.gitlab_url is None:
-            print("Arguments for gitlab url must be provided.")
-            sys.exit(1)
-        if args.gitlab_project_id is not None:
-            remove_project_account(args.gitlab_url, args.gitlab_project_id, os.path.abspath(args.private_key_path),
-                                   os.path.abspath(args.public_key_path),
-                                   os.path.abspath(args.mapping_path), os.path.abspath(args.aes_encryption_key_path))
-        elif args.gitlab_user_id is not None:
-            remove_user_account(args.gitlab_url, args.gitlab_user_id, os.path.abspath(args.private_key_path),
-                                os.path.abspath(args.public_key_path),
-                                os.path.abspath(args.mapping_path), os.path.abspath(args.aes_encryption_key_path))
-        else:
-            print("Arguments for gitlab project id or gitlab user id must be provided.")
-            sys.exit(1)
-
-    if args.remove_url:
-        if args.gitlab_url is None:
-            print("Argument for gitlab url must be provided.")
-            sys.exit(1)
-        else:
-            remove_url(args.gitlab_url, os.path.abspath(args.private_key_path), os.path.abspath(args.public_key_path),
-                       os.path.abspath(args.mapping_path), os.path.abspath(args.aes_encryption_key_path))
-
-    if args.add_mapping:
-        if args.gitlab_url is None:
-            print("Argument for gitlab url must be provided.")
-            sys.exit(1)
-        if args.cluster_account_name is None:
-            print("Argument for cluster account name must be provided.")
-            sys.exit(1)
-        if args.delete_date is None:
-            print("Argument for delete date must be provided.")
-            sys.exit(1)
-        if args.gitlab_project_id is not None:
-            add_project_account(args.gitlab_url, args.gitlab_project_id, os.path.abspath(args.private_key_path),
-                                os.path.abspath(args.public_key_path), os.path.abspath(args.mapping_path),
-                                args.cluster_account_name, args.delete_date,
-                                os.path.abspath(args.aes_encryption_key_path))
-        elif args.gitlab_user_id is not None:
-            add_user_account(args.gitlab_url, args.gitlab_user_id, os.path.abspath(args.private_key_path),
-                             os.path.abspath(args.public_key_path), os.path.abspath(args.mapping_path),
-                             args.cluster_account_name, args.delete_date, os.path.abspath(args.aes_encryption_key_path))
-        else:
-            print("Arguments for gitlab project id or gitlab user id must be provided.")
-            sys.exit(1)
-
-
-run()
\ No newline at end of file
diff --git a/core/authentication/EncryptionManager.py b/core/authentication/EncryptionManager.py
index 658e205..76e086d 100644
--- a/core/authentication/EncryptionManager.py
+++ b/core/authentication/EncryptionManager.py
@@ -9,14 +9,12 @@ from Crypto.Cipher import AES
 
 
 def load_priv_key(path):
-    path = os.path.join(os.path.dirname(__file__), path)
     with open(path, mode='rb') as private_file:
         key_data = private_file.read()
     return rsa.PrivateKey.load_pkcs1(key_data)
 
 
 def load_pub_key(path):
-    path = os.path.join(os.path.dirname(__file__), path)
     with open(path, mode='rb') as public_file:
         key_data = public_file.read()
     return rsa.PublicKey.load_pkcs1(key_data)
diff --git a/core/utility/cli.py b/core/utility/cli.py
new file mode 100644
index 0000000..3bdb940
--- /dev/null
+++ b/core/utility/cli.py
@@ -0,0 +1,22 @@
+def add_parameters(parser, param_list):
+    for p in param_list:
+        if p.get("action"):
+            parser.add_argument(
+                *p.get("flags"),
+                required=p.get("required"),
+                default=p.get("default"),
+                action=p.get("action"),
+                help=p.get("help")
+            )
+        else:
+            parser.add_argument(
+                *p.get("flags"),
+                required=p.get("required"),
+                type=p.get("type"),
+                choices=p.get("choices"),
+                nargs=p.get("nargs"),
+                default=p.get("default"),
+                const=p.get("const"),
+                metavar=p.get("metavar"),
+                help=p.get("help")
+            )
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/account_manager/__init__.py b/test/account_manager/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/account_manager/test_manager.py b/test/account_manager/test_manager.py
new file mode 100644
index 0000000..33db0cc
--- /dev/null
+++ b/test/account_manager/test_manager.py
@@ -0,0 +1,78 @@
+import shutil
+import tempfile
+import os
+
+import unittest
+import AccountManager as manager
+
+
+class TestAccManager(unittest.TestCase):
+    def setUp(self):
+        self.test_dir = tempfile.mkdtemp()
+        self.global_args = [f'--base-path={self.test_dir}']
+        manager._setup_logging('DEBUG', f'{self.test_dir}/history.log', std=False)
+
+    def _run(self, command, command_opts=[]):
+        self.args = manager.CLI(self.global_args + [command] + command_opts)
+        self.args.func(self.args)
+
+    def _init(self):
+        self._run('init')
+
+    def _add_id(self, id, url, accname, id_type='uid'):
+        s_type = 'user' if id_type=='uid' else 'project'
+        self._run('add', [f'--gitlab-{s_type}-id={id}', f'--gitlab-url={url}', f'--cluster-account-name={accname}'])
+
+    def _rm_id(self, id, url, id_type='uid'):
+        s_type = 'user' if id_type=='uid' else 'project'
+        self._run('remove', [f'--gitlab-{s_type}-id={id}', f'--gitlab-url={url}'])
+
+    def _rm_url(self, url):
+        self._run('remove', [f'--gitlab-url={url}'])
+
+    def tearDown(self):
+#        with open(f'{self.test_dir}/history.log', 'r') as history:
+#            print(history.readlines())
+        shutil.rmtree(self.test_dir)
+
+
+class TestInit(TestAccManager):
+    def test_init(self):
+        self._init()
+        files = (self.args.priv_key_file, self.args.pub_key_file,
+                 self.args.mapping_file, self.args.aes_key_file)
+        for file in files:
+            self.assertTrue(os.path.isfile(file))
+
+
+class TestAdd(TestAccManager):
+    def _test_add_id(self, id='0', url='gitlab.com', accname='john', id_type='uid'):
+        self._init()
+        self._add_id(id, url, accname, id_type)
+        self.assertTrue(manager._get_mapping(self.args)[url][id_type][id]['acc'] == accname)
+
+    def test_add_uid(self):
+        self._test_add_id()
+
+
+    def test_add_pid(self):
+        self._test_add_id(id_type='pid')
+
+
+class TestRemove(TestAccManager):
+    def _test_rm_id(self, id='0', url='gitlab.com', accname='john', id_type='uid'):
+        self._init()
+        self._add_id(id, url, accname, id_type)
+        self.assertTrue(manager._get_mapping(self.args)[url][id_type][id]['acc'] == accname)
+        self._rm_id(id, url, id_type)
+        self.assertFalse(manager._id_present(self.args, id, id_type))
+
+    def test_remove_uid(self):
+        self._test_rm_id()
+
+    def test_remove_pid(self):
+        self._test_rm_id(id_type='pid')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/utility/.gitlab/.unittest.yml b/utility/.gitlab/.unittest.yml
new file mode 100644
index 0000000..5d91898
--- /dev/null
+++ b/utility/.gitlab/.unittest.yml
@@ -0,0 +1,18 @@
+unittest:
+  stage: unittest
+  tags: ['docker']
+  needs: []
+  image: ubuntu:22.04
+  before_script:
+    - export DEBIAN_FRONTEND=noninteractive
+    - apt-get update
+    - apt-get install -y --no-install-recommends python3 python3-pip
+    - pip3 install -r requirements.txt
+    - pip3 install pytest
+  script:
+    - python3 -m pytest -o junit_suite_name=acc_manager --junitxml=report.xml test/account_manager
+  artifacts:
+    when: always
+    reports:
+      junit: report.xml
+
-- 
GitLab