From 9b88778c43c79ebe753fbc6f4d8781f8c4e63e05 Mon Sep 17 00:00:00 2001 From: Romain Le Jeune Date: Tue, 1 Aug 2023 12:47:16 +0000 Subject: [PATCH] feat(GODT-2788): Add JSON validator file. --- Makefile | 5 +- .../bridge-gui/qml/QuestionItem.qml | 37 ++-- .../qml/Resources/bug_report_flow.json | 15 +- utils/validate_bug_report_file.py | 203 ++++++++++++++++++ 4 files changed, 232 insertions(+), 28 deletions(-) create mode 100755 utils/validate_bug_report_file.py diff --git a/Makefile b/Makefile index 0ff439e8..067add8a 100644 --- a/Makefile +++ b/Makefile @@ -291,7 +291,7 @@ MessageSubscriber,LabelSubscriber,AddressSubscriber,RefreshSubscriber,UserSubscr mockgen --package mocks github.com/ProtonMail/proton-bridge/v3/internal/services/useridentity IdentityProvider,Telemetry \ > internal/services/useridentity/mocks/mocks.go -lint: gofiles lint-golang lint-license lint-dependencies lint-changelog +lint: gofiles lint-golang lint-license lint-dependencies lint-changelog lint-bug-report lint-license: ./utils/missing_license.sh check @@ -307,6 +307,9 @@ lint-golang: $(info linting with GOMAXPROCS=${GOMAXPROCS}) golangci-lint run ./... +lint-bug-report: + python3 utils/validate_bug_report_file.py --file "internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json" + gobinsec: gobinsec-cache.yml build gobinsec -wait -cache -config utils/gobinsec_conf.yml ${EXE_TARGET} ${DEPLOY_DIR}/${TARGET_OS}/${LAUNCHER_EXE} diff --git a/internal/frontend/bridge-gui/bridge-gui/qml/QuestionItem.qml b/internal/frontend/bridge-gui/bridge-gui/qml/QuestionItem.qml index 2db58c0a..24170ffe 100644 --- a/internal/frontend/bridge-gui/bridge-gui/qml/QuestionItem.qml +++ b/internal/frontend/bridge-gui/bridge-gui/qml/QuestionItem.qml @@ -23,6 +23,9 @@ Item { Checkbox } + property string _typeOpen: "open" + property string _typeChoice: "choice" + property string _typeMutlichoice: "multichoice" property var colorScheme property var _bottomMargin: 20 property var _lineHeight: 1 @@ -32,26 +35,26 @@ Item { property string tips: "" property string label: "" property bool mandatory: false - property var type: QuestionItem.InputType.TextInput + property var type: root._typeOpen property var answerList: ListModel{} property int maxChar: 150 property string answer:{ - if (type === QuestionItem.InputType.TextInput) { + if (type === root._typeOpen) { return textInput.text - } else if (type === QuestionItem.InputType.Radio) { + } else if (type === root._typeChoice) { return selectionRadio.text - } else if (type === QuestionItem.InputType.Checkbox) { + } else if (type === root._typeMutlichoice) { return selectionCheckBox.text } return "" } property bool error: { - if (root.type === QuestionItem.InputType.TextInput) + if (root.type === root._typeOpen) return textInput.error; - if (root.type === QuestionItem.InputType.Radio) + if (root.type === root._typeChoice) return selectionRadio.error; - if (root.type === QuestionItem.InputType.Checkbox) + if (root.type === root._typeMutlichoice) return selectionCheckBox.error; return false } @@ -64,11 +67,11 @@ Item { function validate() { - if (root.type === QuestionItem.InputType.TextInput) + if (root.type === root._typeOpen) textInput.validate() - else if (root.type === QuestionItem.InputType.Radio) + else if (root.type === root._typeChoice) selectionRadio.validate() - else if (root.type === QuestionItem.InputType.Checkbox) + else if (root.type === root._typeMutlichoice) selectionCheckBox.validate() } @@ -91,7 +94,7 @@ Item { id: textInput Layout.fillWidth: true Layout.fillHeight: true - Layout.minimumHeight: root.type === QuestionItem.InputType.TextInput ? heightForLinesVisible(2) : 0 + Layout.minimumHeight: root.type === root._typeOpen ? heightForLinesVisible(2) : 0 colorScheme: root.colorScheme property int _maxLength: root.maxChar @@ -102,7 +105,7 @@ Item { placeholderText: mandatory ? qsTr("%1... (min. %2 characters)").arg(root.text).arg(_minLength) : "" function setDefaultValue(defaultValue) { - textInput.text = root.type === QuestionItem.InputType.TextInput ? defaultValue : "" + textInput.text = root.type === root._typeOpen ? defaultValue : "" } validator: function (text) { @@ -121,7 +124,7 @@ Item { } } - visible: root.type === QuestionItem.InputType.TextInput + visible: root.type === root._typeOpen } ButtonGroup { @@ -133,7 +136,7 @@ Item { property bool error: root.mandatory function setDefaultValue(defaultValue) { - const values = root.type === QuestionItem.InputType.Radio ? defaultValue : []; + const values = root.type === root._typeChoice ? defaultValue : []; for (var i = 0; i < buttons.length; ++i) { buttons[i].checked = values.includes(buttons[i].text); } @@ -157,7 +160,7 @@ Item { ButtonGroup.group: selectionRadio colorScheme: root.colorScheme text: modelData - visible: root.type === QuestionItem.InputType.Radio + visible: root.type === root._typeChoice } } ButtonGroup { @@ -176,7 +179,7 @@ Item { property bool error: root.mandatory function setDefaultValue(defaultValue) { - const values = root.type === QuestionItem.InputType.Checkbox ? defaultValue.split(delimitor) : []; + const values = root.type === root._typeMutlichoice ? defaultValue.split(delimitor) : []; for (var i = 0; i < buttons.length; ++i) { buttons[i].checked = values.includes(buttons[i].text); } @@ -201,7 +204,7 @@ Item { ButtonGroup.group: selectionCheckBox colorScheme: root.colorScheme text: modelData - visible: root.type === QuestionItem.InputType.Checkbox + visible: root.type === root._typeMutlichoice } } } diff --git a/internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json b/internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json index 391c31f7..47e157e6 100644 --- a/internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json +++ b/internal/frontend/bridge-gui/bridge-gui/qml/Resources/bug_report_flow.json @@ -5,27 +5,22 @@ "data_v1.0.0": { "categories": [ { - "id": 0, "name": "I can't receive mail", "questions": [0,1,2,3,4] }, { - "id": 1, "name": "I can't send mail", "questions": [0,1,2,3,4] }, { - "id": 2, "name": "Bridge is not starting", "questions": [0,1,2,3] }, { - "id": 3, "name": "Bridge is slow", "questions": [0,1,2,3] }, { - "id": 4, "name": "None of the above", "questions": [0,1,2,3] } @@ -35,7 +30,7 @@ "id": 0, "text": "What happened?", "tips": "Expected behavior", - "type": 1, + "type": "open", "mandatory": true, "maxChar": 400 }, @@ -43,7 +38,7 @@ "id": 1, "text": "What did you want or expect to happen?", "tips": "Result", - "type": 1, + "type": "open", "mandatory": true, "maxChar": 400 }, @@ -51,19 +46,19 @@ "id": 2, "text": "What were the step-by-step actions you took that led to this happening?", "tips": "Steps to reproduce", - "type": 1, + "type": "open", "maxChar": 400 }, { "id": 3, "text": "Can you reproduce this issue? (If you repeat the actions, the same thing happens.)", - "type": 2, + "type": "choice", "answerList": ["Yes", "No", "I don't know"] }, { "id": 4, "text": "Can you list the software you are running?", - "type": 3, + "type": "multichoice", "answerList": ["VPN", "Antivirus", "Firewall", "Cache cleaner"] } ] diff --git a/utils/validate_bug_report_file.py b/utils/validate_bug_report_file.py new file mode 100755 index 00000000..4c4d0824 --- /dev/null +++ b/utils/validate_bug_report_file.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2023 Proton AG +# +# This file is part of Proton Mail Bridge. +# +# Proton Mail Bridge is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Proton Mail Bridge is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Proton Mail Bridge. If not, see . + +import argparse +import json +import re + + +class BugReportJson: + + def __init__(self, filepath): + self.filepath = filepath + self.json = None + self.metadata = None + self.version = None + self.data = None + self.categories = None + self.questions = None + self.questionsID = [] + self.error = "" + + def validate(self): + with open(self.filepath) as infile: + self.json = json.load(infile) + if self.json is None: + return False, ("JSON cannot be load from %s." % self.filepath) + + for object in self.json: + if not (object == "metadata" or re.match(r"data_v[0-9]+\.[0-9]+\.[0-9]+", object)) : + self.error = ("Unexpected object name %s." % object) + return False + + if not self.parse_metadata(): + return False + if not self.parse_data(): + return False + if not self.parse_questions(): + return False + if not self.parse_categories(): + return False + return True + + def parse_metadata(self): + if "metadata" not in self.json: + self.error = "No metadata object." + return False + if not isinstance(self.json["metadata"], dict): + self.error = "metadata should be a dictionary." + return False + + self.metadata = self.json["metadata"] + if "version" not in self.metadata: + self.error = "No version in metadata object." + return False + + self.version = self.metadata["version"] + if not re.match(r"[0-9]+\.[0-9]+\.[0-9]+", self.version): + self.error = ("Version (%s) doesn't match pattern." % self.version) + return False + return True + + def parse_data(self): + data_version = ("data_v%s" % self.version) + if data_version not in self.json: + self.error = ("No data object matching version %s." % self.version) + return False + + if not isinstance(self.json[data_version], dict): + self.error = ("%s should be a dictionary." %data_version) + return False + + self.data = self.json[data_version] + + if "categories" not in self.data: + self.error = "No categories object in data." + return False + self.categories = self.data["categories"] + if not isinstance(self.categories, list): + self.error = "categories should be an array." + return False + + if "questions" not in self.data: + self.error = "No questions object in data." + return False + self.questions = self.data["questions"] + if not isinstance(self.questions, list): + self.error = "questions should be an array." + return False + return True + + def parse_questions(self): + for question in self.questions: + if not isinstance(question, dict): + self.error = ("Question should be a dictionary.") + return False + for option in question: + if option not in ["id", "text", "tips", "type", "mandatory", "maxChar", "answerList"]: + self.error = ("Unexpected option '%s' in question." % option) + return False + # check mandatory field + if "id" not in question: + self.error = ("Missing id in question %s." % question) + return False + if question["id"] in self.questionsID: + self.error = ("Question id should be unique (%d)." % question["id"]) + return False + self.questionsID.append(question["id"]) + + if "text" not in question: + self.error = ("Missing text in question %s." % question) + return False + + if "type" not in question: + self.error = ("Missing type in question %s." % question) + return False + + # check type restriction + if question["type"] == "open": + if "maxChar" in question: + if question["maxChar"] > 1000: + self.error = ("MaxChar is too damn high in question %s." % question) + return False + if "answerList" in question: + self.error = ("AnswerList should not be present in open question %s." % question) + return False + elif question["type"] == "choice" or question["type"] == "multichoice": + if "answerList" not in question: + self.error = ("Missing answerList in question %s." % question) + return False + if not isinstance(question["answerList"], list): + self.error = ("AnswerList should be an array in question %s." % question) + return False + if "maxChar" in question: + self.error = ("maxChar should not be present in choice/multichoice question %s." % question) + return False + else: + self.error = ("Wrong type in question %s." % question) + return False + return True + + def parse_categories(self): + for category in self.categories: + if not isinstance(category, dict): + self.error = ("category should be a dictionary.") + return False + for option in category: + if option not in ["name", "questions"]: + self.error = ("Unexpected option '%s' in category." % option) + return False + if "name" not in category: + self.error = ("Missing name in category %s." % category) + return False + if "questions" not in category: + self.error = ("Missing questions in category %s." % category) + return False + unique_list = [] + for question in category["questions"]: + if question not in self.questionsID: + self.error = ("Questions referring to non-existing question in category %s." % category) + return False + if question in unique_list: + self.error = ("Questions contains duplicate in category %s." % category) + return False + unique_list.append(question) + return True + + +def parse_args(): + parser = argparse.ArgumentParser(description='Validate Bug Report File.') + parser.add_argument('--file', required=True, help='JSON file to validate.') + return parser.parse_args() + + +def main(): + args = parse_args() + report = BugReportJson(args.file) + + if not report.validate(): + print("Validation FAILED for %s. Error: %s" %(report.filepath, report.error)) + exit(1) + print("Validation SUCCEED for %s." % report.filepath) + exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file