From 4749eccfa51dbdb1cc989565a0659f4c647e1f3b Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Mon, 13 Apr 2020 22:23:58 +0200 Subject: [PATCH] Added fix for python2 regex Fix for python2 attributeError instead of TypeError on login with wrong openLDAP setting Added default empty string on LDAPCertificate Fix ldap as scheme for tls connection Enabled add user on LDAP Authentication LDAP config port is now number input Added header for user import config Added python ldap version to about section Fix: It's no longer possible to login via fallback password as long as LDAP server is available Fix: TypeError on bind is now catched and transformed to error message Update Readme Fixes for ldap --- README.md | 6 +- cps/about.py | 1 + cps/admin.py | 72 ++- cps/config_sql.py | 10 +- cps/db.py | 7 +- cps/services/__init__.py | 5 +- cps/services/simpleldap.py | 35 +- cps/templates/admin.html | 3 +- cps/templates/config_edit.html | 49 +- cps/ub.py | 10 +- cps/web.py | 96 +++- test/Calibre-Web TestSummary.html | 910 +++++++++++++++++++++--------- 12 files changed, 815 insertions(+), 389 deletions(-) mode change 100644 => 100755 test/Calibre-Web TestSummary.html diff --git a/README.md b/README.md index 849cd1fd..88db2126 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d - Send eBooks to Kindle devices with the click of a button - Sync your Kobo devices through Calibre-Web with your Calibre library - Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz) -- Upload new books in many formats -- Support for Calibre custom columns +- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b) +- Support for Calibre Custom Columns - Ability to hide content based on categories and Custom Column content per user - Self-update capability - "Magic Link" login to make it easy to log on eReaders -- Login via google/github oauth and via proxy authentication +- Login via LDAP, google/github oauth and via proxy authentication ## Quick start diff --git a/cps/about.py b/cps/about.py index fd52ca7b..dfc6c502 100644 --- a/cps/about.py +++ b/cps/about.py @@ -69,6 +69,7 @@ _VERSIONS = OrderedDict( pytz=pytz.__version__, Unidecode = unidecode_version, Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed', + python_LDAP = services.ldapVersion if bool(services.ldapVersion) else u'not installed', Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed', jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else u'not installed', ) diff --git a/cps/admin.py b/cps/admin.py index 726071d3..b2f740bc 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -504,7 +504,7 @@ def _configuration_update_helper(): with open(gdriveutils.CLIENT_SECRETS, 'r') as settings: gdrive_secrets = json.load(settings)['web'] if not gdrive_secrets: - return _configuration_result('client_secrets.json is not configured for web application') + return _configuration_result(_('client_secrets.json Is Not Configured For Web Application')) gdriveutils.update_settings( gdrive_secrets['client_id'], gdrive_secrets['client_secret'], @@ -520,11 +520,11 @@ def _configuration_update_helper(): reboot_required |= _config_string("config_keyfile") if config.config_keyfile and not os.path.isfile(config.config_keyfile): - return _configuration_result('Keyfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Keyfile Location is not Valid, Please Enter Correct Path'), gdriveError) reboot_required |= _config_string("config_certfile") if config.config_certfile and not os.path.isfile(config.config_certfile): - return _configuration_result('Certfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Certfile Location is not Valid, Please Enter Correct Path'), gdriveError) _config_checkbox_int("config_uploading") _config_checkbox_int("config_anonbrowse") @@ -542,45 +542,57 @@ def _configuration_update_helper(): #LDAP configurator, if config.config_login_type == constants.LOGIN_LDAP: - _config_string("config_ldap_provider_url") - _config_int("config_ldap_port") + reboot_required |= _config_string("config_ldap_provider_url") + reboot_required |= _config_int("config_ldap_port") # _config_string("config_ldap_schema") - _config_string("config_ldap_dn") - _config_string("config_ldap_user_object") + reboot_required |= _config_string("config_ldap_dn") + reboot_required |= _config_string("config_ldap_serv_username") + reboot_required |= _config_string("config_ldap_user_object") + reboot_required |= _config_string("config_ldap_group_object_filter") + reboot_required |= _config_string("config_ldap_group_members_field") + reboot_required |= _config_checkbox("config_ldap_openldap") + reboot_required |= _config_int("config_ldap_encryption") + reboot_required |= _config_string("config_ldap_cert_path") + _config_string("config_ldap_group_name") + if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"] != "": + reboot_required |= 1 + config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') + config.save() + if not config.config_ldap_provider_url \ or not config.config_ldap_port \ or not config.config_ldap_dn \ or not config.config_ldap_user_object: - return _configuration_result('Please enter a LDAP provider, ' - 'port, DN and user object identifier', gdriveError) + return _configuration_result(_('Please Enter a LDAP Provider, ' + 'Port, DN and User Object Identifier'), gdriveError) - _config_string("config_ldap_serv_username") - if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"]: - config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') - if not config.config_ldap_serv_username and not config.config_ldap_serv_password: - return _configuration_result('Please enter a LDAP service account and password', gdriveError) + if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password): + return _configuration_result('Please Enter a LDAP Service Account and Password', gdriveError) - _config_string("config_ldap_group_object_filter") - _config_string("config_ldap_group_members_field") - _config_string("config_ldap_group_name") #_config_checkbox("config_ldap_use_ssl") #_config_checkbox("config_ldap_use_tls") - _config_int("config_ldap_encryption") - _config_checkbox("config_ldap_openldap") + # reboot_required |= _config_checkbox("config_ldap_openldap") # _config_checkbox("config_ldap_require_cert") - _config_string("config_ldap_cert_path") - if config.config_ldap_group_object_filter.count("%s") != 1: - return _configuration_result('LDAP Group Object Filter Needs to Have One "%s" Format Identifier', - gdriveError) + if config.config_ldap_group_object_filter: + if config.config_ldap_group_object_filter.count("%s") != 1: + return _configuration_result(_('LDAP Group Object Filter Needs to Have One "%s" Format Identifier'), + gdriveError) + if config.config_ldap_group_object_filter.count("(") != config.config_ldap_group_object_filter.count(")"): + return _configuration_result(_('LDAP Group Object Filter Has Unmatched Parenthesis'), + gdriveError) if config.config_ldap_user_object.count("%s") != 1: - return _configuration_result('LDAP User Object Filter needs to Have One "%s" Format Identifier', + return _configuration_result(_('LDAP User Object Filter needs to Have One "%s" Format Identifier'), + gdriveError) + if config.config_ldap_user_object.count("(") != config.config_ldap_user_object.count(")"): + return _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'), gdriveError) - if config.config_ldap_cert_path and not os.path.isfile(config.config_ldap_cert_path): - return _configuration_result('LDAP Certfile location is not valid, please enter correct path', gdriveError) + if config.config_ldap_cert_path and not os.path.isdir(config.config_ldap_cert_path): + return _configuration_result(_('LDAP Certificate Location is not Valid, Please Enter Correct Path'), + gdriveError) # Remote login configuration _config_checkbox("config_remote_login") @@ -628,12 +640,12 @@ def _configuration_update_helper(): reboot_required |= _config_int("config_log_level") reboot_required |= _config_string("config_logfile") if not logger.is_valid_logfile(config.config_logfile): - return _configuration_result('Logfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Logfile Location is not Valid, Please Enter Correct Path'), gdriveError) reboot_required |= _config_checkbox_int("config_access_log") reboot_required |= _config_string("config_access_logfile") if not logger.is_valid_logfile(config.config_access_logfile): - return _configuration_result('Access Logfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Access Logfile Location is not Valid, Please Enter Correct Path'), gdriveError) # Rarfile Content configuration _config_string("config_rarfile_location") @@ -652,7 +664,7 @@ def _configuration_update_helper(): if db_change: # reload(db) if not db.setup_db(config): - return _configuration_result('DB location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdriveError) config.save() flash(_(u"Calibre-Web configuration updated"), category="success") @@ -678,7 +690,7 @@ def _configuration_result(error_flash=None, gdriveError=None): show_login_button = config.db_configured and not current_user.is_authenticated if error_flash: config.load() - flash(_(error_flash), category="error") + flash(error_flash, category="error") show_login_button = False return render_title_template("config_edit.html", config=config, provider=oauthblueprints, diff --git a/cps/config_sql.py b/cps/config_sql.py index 98b30416..7fc99a91 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -73,7 +73,7 @@ class _Settings(_Base): config_kobo_sync = Column(Boolean, default=False) config_default_role = Column(SmallInteger, default=0) - config_default_show = Column(SmallInteger, default=6143) + config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR) config_columns_to_ignore = Column(String) config_denied_tags = Column(String, default="") @@ -99,11 +99,11 @@ class _Settings(_Base): config_ldap_port = Column(SmallInteger, default=389) # config_ldap_schema = Column(String, default='ldap') config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org') - config_ldap_serv_password = Column(String) + config_ldap_serv_password = Column(String, default="") config_ldap_encryption = Column(SmallInteger, default=0) # config_ldap_use_tls = Column(Boolean, default=False) # config_ldap_require_cert = Column(Boolean, default=False) - config_ldap_cert_path = Column(String) + config_ldap_cert_path = Column(String, default="") config_ldap_dn = Column(String, default='dc=example,dc=org') config_ldap_user_object = Column(String, default='uid=%s') config_ldap_openldap = Column(Boolean, default=True) @@ -285,7 +285,9 @@ class _ConfigSQL(object): self._session.commit() self.load() - def invalidate(self): + def invalidate(self, error=None): + if error: + log.error(error) log.warning("invalidating configuration") self.db_configured = False self.config_calibre_dir = None diff --git a/cps/db.py b/cps/db.py index f40e0cda..966adbe5 100755 --- a/cps/db.py +++ b/cps/db.py @@ -344,14 +344,13 @@ def setup_db(config): isolation_level="SERIALIZABLE", connect_args={'check_same_thread': False}) conn = engine.connect() - except: - config.invalidate() + # conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302 + except Exception as e: + config.invalidate(e) return False config.db_configured = True update_title_sort(config, conn.connection) - # conn.connection.create_function('lower', 1, lcase) - # conn.connection.create_function('upper', 1, ucase) if not cc_classes: cc = conn.execute("SELECT id, datatype FROM custom_columns") diff --git a/cps/services/__init__.py b/cps/services/__init__.py index 2eb82f0d..11ef4c65 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -30,10 +30,13 @@ except ImportError as err: goodreads_support = None -try: from . import simpleldap as ldap +try: + from . import simpleldap as ldap + from .simpleldap import ldapVersion except ImportError as err: log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err) ldap = None + ldapVersion = None try: from . import SyncToken as SyncToken diff --git a/cps/services/simpleldap.py b/cps/services/simpleldap.py index 82690d87..841f61e1 100644 --- a/cps/services/simpleldap.py +++ b/cps/services/simpleldap.py @@ -23,6 +23,10 @@ from flask_simpleldap import LDAP, LDAPException from .. import constants, logger +try: + from ldap.pkginfo import __version__ as ldapVersion +except ImportError: + pass log = logger.create() _ldap = LDAP() @@ -34,14 +38,16 @@ def init_app(app, config): app.config['LDAP_HOST'] = config.config_ldap_provider_url app.config['LDAP_PORT'] = config.config_ldap_port - if config.config_ldap_encryption: + if config.config_ldap_encryption == 2: app.config['LDAP_SCHEMA'] = 'ldaps' else: app.config['LDAP_SCHEMA'] = 'ldap' # app.config['LDAP_SCHEMA'] = config.config_ldap_schema app.config['LDAP_USERNAME'] = config.config_ldap_serv_username + if config.config_ldap_serv_password is None: + config.config_ldap_serv_password = '' app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password) - if config.config_ldap_cert_path: + if bool(config.config_ldap_cert_path): app.config['LDAP_REQUIRE_CERT'] = True app.config['LDAP_CERT_PATH'] = config.config_ldap_cert_path app.config['LDAP_BASE_DN'] = config.config_ldap_dn @@ -52,6 +58,7 @@ def init_app(app, config): app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap) app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field + # app.config['LDAP_CUSTOM_OPTIONS'] = {'OPT_NETWORK_TIMEOUT': 10} _ldap.init_app(app) @@ -78,16 +85,22 @@ def bind_user(username, password): :returns: True if login succeeded, False if login failed, None if server unavailable. ''' try: - result = _ldap.bind_user(username, password) - log.debug("LDAP login '%s': %r", username, result) - return result is not None + if _ldap.get_object_details(username): + result = _ldap.bind_user(username, password) + log.debug("LDAP login '%s': %r", username, result) + return result is not None, None + return None, None # User not found + except (TypeError, AttributeError) as ex: + error = ("LDAP bind_user: %s" % ex) + return None, error except LDAPException as ex: if ex.message == 'Invalid credentials': - log.info("LDAP login '%s' failed: %s", username, ex) - return False + error = ("LDAP admin login failed") + return None, error if ex.message == "Can't contact LDAP server": - log.warning('LDAP Server down: %s', ex) - return None + # log.warning('LDAP Server down: %s', ex) + error = ('LDAP Server down: %s' % ex) + return None, error else: - log.warning('LDAP Server error: %s', ex.message) - return None + error = ('LDAP Server error: %s' % ex.message) + return None, error diff --git a/cps/templates/admin.html b/cps/templates/admin.html index b548f021..ab143c59 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -35,9 +35,8 @@ {% endif %} {% endfor %} - {% if not (config.config_login_type == 1) %}
{{_('Add New User')}}
- {% else %} + {% if (config.config_login_type == 1) %}
{{_('Import LDAP Users')}}
{% endif %} diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 5b114739..e9abdfde 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -230,18 +230,11 @@
- +
- - -
-
- - -
-
- + +
+ +
+ + +
+
+ + +
@@ -266,18 +268,19 @@
-
- - -
-
- - -
-
- - -
+

{{_('Following Settings are Needed For User Import')}}

+
+ + +
+
+ + +
+
+ + +
{% endif %} {% if feature_support['oauth'] %} diff --git a/cps/ub.py b/cps/ub.py index 21dc6e3c..13e996cc 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -232,7 +232,7 @@ class Anonymous(AnonymousUserMixin, UserBase): self.sidebar_view = data.sidebar_view self.default_language = data.default_language self.locale = data.locale - self.mature_content = data.mature_content + # self.mature_content = data.mature_content self.kindle_mail = data.kindle_mail self.denied_tags = data.denied_tags self.allowed_tags = data.allowed_tags @@ -441,14 +441,12 @@ def migrate_Database(session): "locale VARCHAR(2)," "sidebar_view INTEGER," "default_language VARCHAR(3)," - "mature_content BOOLEAN," "UNIQUE (nickname)," - "UNIQUE (email)," - "CHECK (mature_content IN (0, 1)))") + "UNIQUE (email))") conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,locale," - "sidebar_view, default_language, mature_content) " + "sidebar_view, default_language) " "SELECT id, nickname, email, role, password, kindle_mail, locale," - "sidebar_view, default_language, mature_content FROM user") + "sidebar_view, default_language FROM user") # delete old user table and rename new user_id table to user: conn.execute("DROP TABLE user") conn.execute("ALTER TABLE user_id RENAME TO user") diff --git a/cps/web.py b/cps/web.py index e85b7c0e..84831bc2 100644 --- a/cps/web.py +++ b/cps/web.py @@ -28,6 +28,7 @@ import json import mimetypes import traceback import binascii +import re from babel import Locale as LC from babel.dates import format_date @@ -278,31 +279,66 @@ def import_ldap_users(): showtext = {} try: new_users = services.ldap.get_group_members(config.config_ldap_group_name) - except services.ldap.LDAPException as e: + except (services.ldap.LDAPException, TypeError, AttributeError) as e: log.debug(e) - showtext['text'] = _(u'Error : %(ldaperror)s', ldaperror=e) + showtext['text'] = _(u'Error: %(ldaperror)s', ldaperror=e) + return json.dumps(showtext) + if not new_users: + log.debug('LDAP empty response') + showtext['text'] = _(u'Error: No user returned in response of LDAP server') return json.dumps(showtext) for username in new_users: - user_data = services.ldap.get_object_details(user=username, group=None, query_filter=None, dn_only=False) - content = ub.User() - content.nickname = username - content.password = username # dummy password which will be replaced by ldap one - content.email = user_data['mail'][0] - if (len(user_data['mail']) > 1): - content.kindle_mail = user_data['mail'][1] - content.role = config.config_default_role - content.sidebar_view = config.config_default_show - content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT) - ub.session.add(content) - try: - ub.session.commit() - except Exception as e: - log.warning("Failed to create LDAP user: %s - %s", username, e) - ub.session.rollback() - showtext['text'] = _(u'Failed to create at least one LDAP user') + user = username.decode('utf-8') + if '=' in user: + match = re.search("([a-zA-Z0-9-]+)=%s", config.config_ldap_user_object, re.IGNORECASE | re.UNICODE) + if match: + match_filter = match.group(1) + match = re.search(match_filter + "=([[\d\w-]+)", user, re.IGNORECASE | re.UNICODE) + if match: + user = match.group(1) + else: + log.warning("Could Not Parse LDAP User: %s", user) + continue + else: + log.warning("Could Not Parse LDAP User: %s", user) + continue + if ub.session.query(ub.User).filter(ub.User.nickname == user.lower()).first(): + log.warning("LDAP User: %s Already in Database", user) + continue + user_data = services.ldap.get_object_details(user=user, + group=None, + query_filter=None, + dn_only=False) + if user_data: + content = ub.User() + content.nickname = user + content.password = '' # dummy password which will be replaced by ldap one + if 'mail' in user_data: + content.email = user_data['mail'][0].decode('utf-8') + if (len(user_data['mail']) > 1): + content.kindle_mail = user_data['mail'][1].decode('utf-8') + else: + log.debug('No Mail Field Found in LDAP Response') + content.email = user + '@email.com' + content.role = config.config_default_role + content.sidebar_view = config.config_default_show + content.allowed_tags = config.config_allowed_tags + content.denied_tags = config.config_denied_tags + content.allowed_column_value = config.config_allowed_column_value + content.denied_column_value = config.config_denied_column_value + ub.session.add(content) + try: + ub.session.commit() + except Exception as e: + log.warning("Failed to create LDAP user: %s - %s", user, e) + ub.session.rollback() + showtext['text'] = _(u'Failed to Create at Least One LDAP User') + else: + log.warning("LDAP User: %s Not Found", user) + showtext['text'] = _(u'At Least One LDAP User Not Found in Database') if not showtext: - showtext['text'] = _(u'User successfully imported') + showtext['text'] = _(u'User Successfully Imported') return json.dumps(showtext) @@ -844,8 +880,9 @@ def reconnect(): @web.route("/search", methods=["GET"]) @login_required_if_no_ano def search(): - term = request.args.get("query").strip().lower() + term = request.args.get("query") if term: + term.strip().lower() entries = get_search_results(term) ids = list() for element in entries: @@ -1175,24 +1212,27 @@ def login(): form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower())\ .first() - if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user: - login_result = services.ldap.bind_user(form['username'], form['password']) + if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user and form['password'] != "": + login_result, error = services.ldap.bind_user(form['username'], form['password']) + # None if credentials are not matching + # -1 if LDAP Server error + # 0 if wrong passwort if login_result: login_user(user, remember=True) log.debug(u"You are now logged in as: '%s'", user.nickname) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("web.index")) - elif user and check_password_hash(str(user.password), form['password']) and user.nickname != "Guest": + elif login_result is None and user and check_password_hash(str(user.password), form['password']) and user.nickname != "Guest": login_user(user, remember=True) - log.info("LDAP Server Down, Fallback Login as: %(nickname)s", user.nickname) - flash(_(u"LDAP Server Down, Fallback Login as: '%(nickname)s'", + log.info("Local Fallback Login as: '%s'", user.nickname) + flash(_(u"Fallback Login as: '%(nickname)s', LDAP Server not reachable, or user not known", nickname=user.nickname), category="warning") return redirect_back(url_for("web.index")) elif login_result is None: - log.info("Could not login. LDAP server down") - flash(_(u"Could not login. LDAP server down, please contact your administrator"), category="error") + log.info(error) + flash(_(u"Could not login: %(message)s", message=error), category="error") else: ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) log.info('LDAP Login failed for user "%s" IP-adress: %s', form['username'], ipAdress) diff --git a/test/Calibre-Web TestSummary.html b/test/Calibre-Web TestSummary.html old mode 100644 new mode 100755 index d0c8094f..cb9772bf --- a/test/Calibre-Web TestSummary.html +++ b/test/Calibre-Web TestSummary.html @@ -36,17 +36,17 @@
-

Start Time: 2020-04-02 20:21:13

+

Start Time: 2020-04-13 20:58:27

-

Stop Time: 2020-04-02 20:57:57

+

Stop Time: 2020-04-13 21:36:17

-

Duration: 1878.33 s

+

Duration: 2002.47 s

@@ -301,8 +301,8 @@ test_ebook_convert.test_ebook_convert 11 - 11 - 0 + 7 + 4 0 0 @@ -339,11 +339,33 @@ - +
test_convert_email
- PASS + +
+ FAIL +
+ + + + @@ -357,20 +379,64 @@ - +
test_convert_only
- PASS + +
+ FAIL +
+ + + + - +
test_convert_parameter
- PASS + +
+ FAIL +
+ + + + @@ -393,11 +459,33 @@ - +
test_email_only
- PASS + +
+ FAIL +
+ + + + @@ -414,13 +502,13 @@ test_edit_books.test_edit_books - 2 + 30 + 27 0 0 - 0 - 2 + 3 - Detail + Detail @@ -452,19 +540,109 @@ - + -
test_rename_uppercase_lowercase
+
test_download_book
+ + PASS + + + + + + +
test_edit_author
+ + PASS + + + + + + +
test_edit_category
+ + PASS + + + + + + +
test_edit_comments
+ + PASS + + + + + + +
test_edit_custom_bool
+ + PASS + + + + + + +
test_edit_custom_rating
+ + PASS + + + + + + +
test_edit_custom_single_select
+ + PASS + + + + + + +
test_edit_custom_text
+ + PASS + + + + + + +
test_edit_language
+ + PASS + + + + + + +
test_edit_publisher
+ + PASS + + + + + + +
test_edit_publishing_date
- SKIP + SKIP
-