From 80e5550f7da0f636b850ab91ec3016563138343c Mon Sep 17 00:00:00 2001 From: Inex Code Date: Fri, 24 Jun 2022 21:14:20 +0300 Subject: [PATCH] add basic system getters --- selfprivacy_api/app.py | 5 +- selfprivacy_api/graphql/__init__.py | 2 + selfprivacy_api/graphql/queries/api.py | 38 ++++++- selfprivacy_api/graphql/queries/common.py | 4 + selfprivacy_api/graphql/queries/providers.py | 2 + selfprivacy_api/graphql/queries/system.py | 106 ++++++++++++++++-- selfprivacy_api/graphql/schema.py | 3 + selfprivacy_api/resources/common.py | 1 + selfprivacy_api/resources/system.py | 10 +- selfprivacy_api/utils/__init__.py | 7 +- tests/common.py | 11 ++ tests/test_auth.py | 12 +- tests/test_graphql/data/tokens.json | 14 +++ tests/test_graphql/test_api.py | 112 +++++++++++++++++++ 14 files changed, 294 insertions(+), 33 deletions(-) create mode 100644 tests/common.py create mode 100644 tests/test_graphql/data/tokens.json create mode 100644 tests/test_graphql/test_api.py diff --git a/selfprivacy_api/app.py b/selfprivacy_api/app.py index 5c21111..77281e8 100644 --- a/selfprivacy_api/app.py +++ b/selfprivacy_api/app.py @@ -93,10 +93,7 @@ def create_app(test_config=None): return jsonify({}), 404 app.add_url_rule( - "/graphql", - view_func=AsyncGraphQLView.as_view( - "graphql", schema=schema - ) + "/graphql", view_func=AsyncGraphQLView.as_view("graphql", schema=schema) ) if app.config["ENABLE_SWAGGER"] == "1": diff --git a/selfprivacy_api/graphql/__init__.py b/selfprivacy_api/graphql/__init__.py index c1ae87b..5e332f3 100644 --- a/selfprivacy_api/graphql/__init__.py +++ b/selfprivacy_api/graphql/__init__.py @@ -7,8 +7,10 @@ from flask import request from selfprivacy_api.utils.auth import is_token_valid + class IsAuthenticated(BasePermission): """Is authenticated permission""" + message = "You must be authenticated to access this resource." def has_permission(self, source: typing.Any, info: Info, **kwargs) -> bool: diff --git a/selfprivacy_api/graphql/queries/api.py b/selfprivacy_api/graphql/queries/api.py index 5431d7d..b2a81d2 100644 --- a/selfprivacy_api/graphql/queries/api.py +++ b/selfprivacy_api/graphql/queries/api.py @@ -18,20 +18,28 @@ from selfprivacy_api.utils.auth import ( get_token_name, ) + def get_api_version() -> str: """Get API version""" return "1.2.7" + @strawberry.type class ApiDevice: """A single device with SelfPrivacy app installed""" + name: str creation_date: datetime.datetime is_caller: bool + def get_devices() -> typing.List[ApiDevice]: """Get list of devices""" - caller_name = get_token_name(request.headers.get("Authorization").split(" ")[1] if request.headers.get("Authorization") is not None else None) + caller_name = get_token_name( + request.headers.get("Authorization").split(" ")[1] + if request.headers.get("Authorization") is not None + else None + ) tokens = get_tokens_info() return [ ApiDevice( @@ -46,34 +54,52 @@ def get_devices() -> typing.List[ApiDevice]: @strawberry.type class ApiRecoveryKeyStatus: """Recovery key status""" + exists: bool valid: bool creation_date: typing.Optional[datetime.datetime] expiration_date: typing.Optional[datetime.datetime] uses_left: typing.Optional[int] + def get_recovery_key_status() -> ApiRecoveryKeyStatus: """Get recovery key status""" if not is_recovery_token_exists(): return ApiRecoveryKeyStatus( - exists=False, valid=False, creation_date=None, expiration_date=None, uses_left=None + exists=False, + valid=False, + creation_date=None, + expiration_date=None, + uses_left=None, ) status = get_recovery_token_status() if status is None: return ApiRecoveryKeyStatus( - exists=False, valid=False, creation_date=None, expiration_date=None, uses_left=None + exists=False, + valid=False, + creation_date=None, + expiration_date=None, + uses_left=None, ) return ApiRecoveryKeyStatus( exists=True, valid=is_recovery_token_valid(), creation_date=parse_date(status["date"]), - expiration_date=parse_date(status["expiration"]) if status["expiration"] is not None else None, + expiration_date=parse_date(status["expiration"]) + if status["expiration"] is not None + else None, uses_left=status["uses_left"] if status["uses_left"] is not None else None, ) + @strawberry.type class Api: """API access status""" + version: str = strawberry.field(resolver=get_api_version) - devices: typing.List[ApiDevice] = strawberry.field(resolver=get_devices, permission_classes=[IsAuthenticated]) - recovery_key: ApiRecoveryKeyStatus = strawberry.field(resolver=get_recovery_key_status, permission_classes=[IsAuthenticated]) + devices: typing.List[ApiDevice] = strawberry.field( + resolver=get_devices, permission_classes=[IsAuthenticated] + ) + recovery_key: ApiRecoveryKeyStatus = strawberry.field( + resolver=get_recovery_key_status, permission_classes=[IsAuthenticated] + ) diff --git a/selfprivacy_api/graphql/queries/common.py b/selfprivacy_api/graphql/queries/common.py index 9c9c1cb..a1abbdc 100644 --- a/selfprivacy_api/graphql/queries/common.py +++ b/selfprivacy_api/graphql/queries/common.py @@ -4,22 +4,26 @@ import datetime import typing import strawberry + @strawberry.enum class Severity(Enum): """ Severity of an alert. """ + INFO = "INFO" WARNING = "WARNING" ERROR = "ERROR" CRITICAL = "CRITICAL" SUCCESS = "SUCCESS" + @strawberry.type class Alert: """ Alert type. """ + severity: Severity title: str message: str diff --git a/selfprivacy_api/graphql/queries/providers.py b/selfprivacy_api/graphql/queries/providers.py index 6d61bac..774d465 100644 --- a/selfprivacy_api/graphql/queries/providers.py +++ b/selfprivacy_api/graphql/queries/providers.py @@ -4,10 +4,12 @@ import datetime import typing import strawberry + @strawberry.enum class DnsProvider(Enum): CLOUDFLARE = "CLOUDFLARE" + @strawberry.enum class ServerProvider(Enum): HETZNER = "HETZNER" diff --git a/selfprivacy_api/graphql/queries/system.py b/selfprivacy_api/graphql/queries/system.py index f0b84f4..53f0abe 100644 --- a/selfprivacy_api/graphql/queries/system.py +++ b/selfprivacy_api/graphql/queries/system.py @@ -1,68 +1,158 @@ """Common system information and settings""" # pylint: disable=too-few-public-methods +import subprocess import typing import strawberry from selfprivacy_api.graphql.queries.common import Alert from selfprivacy_api.graphql.queries.providers import DnsProvider, ServerProvider +from selfprivacy_api.utils import ReadUserData + @strawberry.type class DnsRecord: """DNS record""" + recordType: str name: str content: str ttl: int priority: typing.Optional[int] + @strawberry.type class SystemDomainInfo: """Information about the system domain""" + domain: str hostname: str provider: DnsProvider required_dns_records: typing.List[DnsRecord] + +def get_system_domain_info() -> SystemDomainInfo: + """Get basic system domain info""" + with ReadUserData() as user_data: + return SystemDomainInfo( + domain=user_data["domain"], + hostname=user_data["hostname"], + provider=DnsProvider.CLOUDFLARE, + # TODO: get ip somehow + required_dns_records=[], + ) + + @strawberry.type class AutoUpgradeOptions: """Automatic upgrade options""" + enable: bool allow_reboot: bool + +def get_auto_upgrade_options() -> AutoUpgradeOptions: + """Get automatic upgrade options""" + with ReadUserData() as user_data: + if "autoUpgrade" not in user_data: + return AutoUpgradeOptions(enable=True, allow_reboot=False) + if "enable" not in user_data["autoUpgrade"]: + user_data["autoUpgrade"]["enable"] = True + if "allowReboot" not in user_data["autoUpgrade"]: + user_data["autoUpgrade"]["allowReboot"] = False + return AutoUpgradeOptions( + enable=user_data["autoUpgrade"]["enable"], + allow_reboot=user_data["autoUpgrade"]["allowReboot"], + ) + + @strawberry.type class SshSettings: """SSH settings and root SSH keys""" + enable: bool password_authentication: bool root_ssh_keys: typing.List[str] + +def get_ssh_settings() -> SshSettings: + """Get SSH settings""" + with ReadUserData() as user_data: + if "ssh" not in user_data: + return SshSettings( + enable=False, password_authentication=False, root_ssh_keys=[] + ) + if "enable" not in user_data["ssh"]: + user_data["ssh"]["enable"] = False + if "passwordAuthentication" not in user_data["ssh"]: + user_data["ssh"]["passwordAuthentication"] = False + if "rootKeys" not in user_data["ssh"]: + user_data["ssh"]["rootKeys"] = [] + return SshSettings( + enable=user_data["ssh"]["enable"], + password_authentication=user_data["ssh"]["passwordAuthentication"], + root_ssh_keys=user_data["ssh"]["rootKeys"], + ) + + +def get_system_timezone() -> str: + """Get system timezone""" + with ReadUserData() as user_data: + if "timezone" not in user_data: + return "Europe/Uzhgorod" + return user_data["timezone"] + + @strawberry.type class SystemSettings: """Common system settings""" - auto_upgrade: AutoUpgradeOptions - ssh: SshSettings - timezone: str + + auto_upgrade: AutoUpgradeOptions = strawberry.field( + resolver=get_auto_upgrade_options + ) + ssh: SshSettings = strawberry.field(resolver=get_ssh_settings) + timezone: str = strawberry.field(resolver=get_system_timezone) + + +def get_system_version() -> str: + """Get system version""" + return subprocess.check_output(["uname", "-a"]).decode("utf-8").strip() + + +def get_python_version() -> str: + """Get Python version""" + return subprocess.check_output(["python", "-V"]).decode("utf-8").strip() + @strawberry.type class SystemInfo: """System components versions""" - system_version: str - python_version: str + + system_version: str = strawberry.field(resolver=get_system_version) + python_version: str = strawberry.field(resolver=get_python_version) + @strawberry.type class SystemProviderInfo: """Information about the VPS/Dedicated server provider""" + provider: ServerProvider id: str + +def get_system_provider_info() -> SystemProviderInfo: + """Get system provider info""" + return SystemProviderInfo(provider=ServerProvider.HETZNER, id="UNKNOWN") + + @strawberry.type class System: """ Base system type which represents common system status """ + status: Alert - domain: SystemDomainInfo + domain: SystemDomainInfo = strawberry.field(resolver=get_system_domain_info) settings: SystemSettings info: SystemInfo - provider: SystemProviderInfo - busy: bool + provider: SystemProviderInfo = strawberry.field(resolver=get_system_provider_info) + busy: bool = False diff --git a/selfprivacy_api/graphql/schema.py b/selfprivacy_api/graphql/schema.py index d3105a5..e11e67e 100644 --- a/selfprivacy_api/graphql/schema.py +++ b/selfprivacy_api/graphql/schema.py @@ -10,10 +10,13 @@ from selfprivacy_api.graphql.queries.system import System @strawberry.type class Query: """Root schema for queries""" + system: System + @strawberry.field def api(self) -> Api: """API access status""" return Api() + schema = strawberry.Schema(query=Query) diff --git a/selfprivacy_api/resources/common.py b/selfprivacy_api/resources/common.py index 93c1dde..9ec060a 100644 --- a/selfprivacy_api/resources/common.py +++ b/selfprivacy_api/resources/common.py @@ -3,6 +3,7 @@ from flask_restful import Resource from selfprivacy_api.graphql.queries.api import get_api_version + class ApiVersion(Resource): """SelfPrivacy API version""" diff --git a/selfprivacy_api/resources/system.py b/selfprivacy_api/resources/system.py index db988da..958616e 100644 --- a/selfprivacy_api/resources/system.py +++ b/selfprivacy_api/resources/system.py @@ -5,6 +5,10 @@ import subprocess import pytz from flask import Blueprint from flask_restful import Resource, Api, reqparse +from selfprivacy_api.graphql.queries.system import ( + get_python_version, + get_system_version, +) from selfprivacy_api.utils import WriteUserData, ReadUserData @@ -256,9 +260,7 @@ class SystemVersion(Resource): description: Unauthorized """ return { - "system_version": subprocess.check_output(["uname", "-a"]) - .decode("utf-8") - .strip() + "system_version": get_system_version(), } @@ -279,7 +281,7 @@ class PythonVersion(Resource): 401: description: Unauthorized """ - return subprocess.check_output(["python", "-V"]).decode("utf-8").strip() + return get_python_version() class PullRepositoryChanges(Resource): diff --git a/selfprivacy_api/utils/__init__.py b/selfprivacy_api/utils/__init__.py index 556ee60..b0d0acc 100644 --- a/selfprivacy_api/utils/__init__.py +++ b/selfprivacy_api/utils/__init__.py @@ -121,7 +121,12 @@ def is_username_forbidden(username): return False + def parse_date(date_str: str) -> datetime.datetime: """Parse date string which can be in %Y-%m-%dT%H:%M:%S.%fZ or %Y-%m-%d %H:%M:%S.%f format""" - return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ") if date_str.endswith("Z") else datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S.%f") + return ( + datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ") + if date_str.endswith("Z") + else datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S.%f") + ) diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..c1b33fe --- /dev/null +++ b/tests/common.py @@ -0,0 +1,11 @@ +import json + + +def read_json(file_path): + with open(file_path, "r", encoding="utf-8") as file: + return json.load(file) + + +def write_json(file_path, data): + with open(file_path, "w", encoding="utf-8") as file: + json.dump(data, file, indent=4) diff --git a/tests/test_auth.py b/tests/test_auth.py index 819a385..10720ca 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,6 +6,8 @@ import re import pytest from mnemonic import Mnemonic +from .common import read_json, write_json + TOKENS_FILE_CONTETS = { "tokens": [ @@ -23,16 +25,6 @@ TOKENS_FILE_CONTETS = { } -def read_json(file_path): - with open(file_path, "r", encoding="utf-8") as file: - return json.load(file) - - -def write_json(file_path, data): - with open(file_path, "w", encoding="utf-8") as file: - json.dump(data, file, indent=4) - - def test_get_tokens_info(authorized_client, tokens_file): response = authorized_client.get("/auth/tokens") assert response.status_code == 200 diff --git a/tests/test_graphql/data/tokens.json b/tests/test_graphql/data/tokens.json new file mode 100644 index 0000000..9be9d02 --- /dev/null +++ b/tests/test_graphql/data/tokens.json @@ -0,0 +1,14 @@ +{ + "tokens": [ + { + "token": "TEST_TOKEN", + "name": "test_token", + "date": "2022-01-14 08:31:10.789314" + }, + { + "token": "TEST_TOKEN2", + "name": "test_token2", + "date": "2022-01-14 08:31:10.789314" + } + ] +} \ No newline at end of file diff --git a/tests/test_graphql/test_api.py b/tests/test_graphql/test_api.py new file mode 100644 index 0000000..fb0aec8 --- /dev/null +++ b/tests/test_graphql/test_api.py @@ -0,0 +1,112 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +import json +import pytest + +TOKENS_FILE_CONTETS = { + "tokens": [ + { + "token": "TEST_TOKEN", + "name": "test_token", + "date": "2022-01-14 08:31:10.789314", + }, + { + "token": "TEST_TOKEN2", + "name": "test_token2", + "date": "2022-01-14 08:31:10.789314", + }, + ] +} + + +def test_graphql_get_api_version(authorized_client): + response = authorized_client.get( + "/graphql", + json={ + "query": """ + query { + api { + version + } + } + """ + }, + ) + assert response.status_code == 200 + assert "version" in response.get_json()["data"]["api"] + + +def test_graphql_api_version_unauthorized(client): + response = client.get( + "/graphql", + json={ + "query": """ + query { + api { + version + } + } + """ + }, + ) + assert response.status_code == 200 + assert "version" in response.get_json()["data"]["api"] + + +def test_graphql_tokens_info(authorized_client, tokens_file): + response = authorized_client.get( + "/graphql", + json={ + "query": """ + query { + api { + devices { + creationDate + isCaller + name + } + } + } + """ + }, + ) + assert response.status_code == 200 + assert response.json == { + "data": { + "api": { + "devices": [ + { + "creationDate": "2022-01-14T08:31:10.789314", + "isCaller": True, + "name": "test_token", + }, + { + "creationDate": "2022-01-14T08:31:10.789314", + "isCaller": False, + "name": "test_token2", + }, + ] + } + } + } + + +def test_graphql_tokens_info_unauthorized(client, tokens_file): + response = client.get( + "/graphql", + json={ + "query": """ + query { + api { + devices { + creationDate + isCaller + name + } + } + } + """ + }, + ) + assert response.status_code == 200 + assert response.json["data"] is None