diff --git a/.drone.yml b/.drone.yml index 0f5f93a..24ab5da 100644 --- a/.drone.yml +++ b/.drone.yml @@ -5,12 +5,16 @@ name: default steps: - name: Run Tests and Generate Coverage Report commands: + - kill $(ps aux | grep '[r]edis-server 127.0.0.1:6389' | awk '{print $2}') + - redis-server --bind 127.0.0.1 --port 6389 >/dev/null & - coverage run -m pytest -q - coverage xml - sonar-scanner -Dsonar.projectKey=SelfPrivacy-REST-API -Dsonar.sources=. -Dsonar.host.url=http://analyzer.lan:9000 -Dsonar.login="$SONARQUBE_TOKEN" environment: SONARQUBE_TOKEN: from_secret: SONARQUBE_TOKEN + USE_REDIS_PORT: 6389 + - name: Run Bandit Checks commands: diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d56657a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9b3fcb7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/selfprivacy-rest-api.iml b/.idea/selfprivacy-rest-api.iml new file mode 100644 index 0000000..86055dc --- /dev/null +++ b/.idea/selfprivacy-rest-api.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..7ddfc9e --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..45ebd2a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# SelfPrivacy API contributors guide + +Instructions for [VScode](https://code.visualstudio.com) or [VScodium](https://github.com/VSCodium/vscodium) under Unix-like platform. + +1. **To get started, create an account for yourself on the** [**SelfPrivacy Gitea**](https://git.selfprivacy.org/user/sign_up). Proceed to fork +the [repository](https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api), and clone it on your local computer: + + ```git clone https://git.selfprivacy.org/your_user_name/selfprivacy-rest-api``` + +2. **Install Nix** + + ```sh <(curl -L https://nixos.org/nix/install)``` + + For detailed installation information, please review and follow: [link](https://nixos.org/manual/nix/stable/installation/installing-binary.html#installing-a-binary-distribution). + +3. **Change directory to the cloned repository and start a nix shell:** + + ```cd selfprivacy-rest-api && nix-shell``` + + Nix will install all of the necessary packages for development work, all further actions will take place only within nix-shell. + +4. **Install these plugins for VScode/VScodium** + + Required: ```ms-python.python```, ```ms-python.vscode-pylance``` + + Optional, but highly recommended: ```ms-python.black-formatter```, ```bbenoist.Nix```, ```ryanluker.vscode-coverage-gutters``` + +5. **Set the path to the python interpreter from the nix store.** To do this, execute the command: + + ```whereis python``` + + Copy the path that starts with ```/nix/store/``` and ends with ```env/bin/python``` + + ```/nix/store/???-python3-3.9.??-env/bin/python``` + + Click on the python version selection in the lower right corner, and replace the path to the interpreter in the project with the one you copied from the terminal. + +6. **Congratulations :) Now you can develop new changes and test the project locally in a Nix environment.** + +## What do you need to know before starting development work? +- RestAPI is no longer utilized, the project has moved to [GraphQL](https://graphql.org), however, the API functionality still works on Rest + + +## What to do after making changes to the repository? + +**Run unit tests** using ```pytest .``` +Make sure that all tests pass successfully and the API works correctly. For convenience, you can use the built-in VScode interface. + +How to review the percentage of code coverage? Execute the command: + +```coverage run -m pytest && coverage xml && coverage report``` + +Next, use the recommended extension ```ryanluker.vscode-coverage-gutters```, navigate to one of the test files, and click the "watch" button on the bottom panel of VScode. + +**Format (linting) code**, we use [black](https://pypi.org/project/black/) formatting, enter +```black .``` to automatically format files, or use the recommended extension. + +**And please remember, we have adopted** [**commit naming convention**](https://www.conventionalcommits.org/en/v1.0.0/), follow the link for more information. + +Please request a review from at least one of the other maintainers. If you are not sure who to request, request a review from SelfPrivacy/Devs team. + +## Helpful links! + +**SelfPrivacy Contributor chat :3** + +- [**Telegram:** @selfprivacy_dev](https://t.me/selfprivacy_dev) +- [**Matrix:** #dev:selfprivacy.org](https://matrix.to/#/#dev:selfprivacy.org) + +**Helpful material to review:** + +- [GraphQL Query Language Documentation](https://graphql.org/) +- [Documentation Strawberry - python library for working with GraphQL](https://strawberry.rocks/docs/) +- [Nix Documentation](https://nixos.org/guides/ad-hoc-developer-environments.html) + +### Track your time + +If you are working on a task, please track your time and add it to the commit message. For example: + +``` +feat: add new feature + +- did some work +- did some more work + +fixes #4, spent @1h30m +``` + +[Timewarrior](https://timewarrior.net/) is a good tool for tracking time. diff --git a/selfprivacy_api/actions/api_tokens.py b/selfprivacy_api/actions/api_tokens.py index 61c695d..38133fd 100644 --- a/selfprivacy_api/actions/api_tokens.py +++ b/selfprivacy_api/actions/api_tokens.py @@ -2,20 +2,19 @@ from datetime import datetime from typing import Optional from pydantic import BaseModel +from mnemonic import Mnemonic - -from selfprivacy_api.utils.auth import ( - delete_token, - generate_recovery_token, - get_recovery_token_status, - get_tokens_info, - is_recovery_token_exists, - is_recovery_token_valid, - is_token_name_exists, - is_token_name_pair_valid, - refresh_token, - get_token_name, +from selfprivacy_api.repositories.tokens.json_tokens_repository import ( + JsonTokensRepository, ) +from selfprivacy_api.repositories.tokens.exceptions import ( + TokenNotFound, + RecoveryKeyNotFound, + InvalidMnemonic, + NewDeviceKeyNotFound, +) + +TOKEN_REPO = JsonTokensRepository() class TokenInfoWithIsCaller(BaseModel): @@ -28,18 +27,23 @@ class TokenInfoWithIsCaller(BaseModel): def get_api_tokens_with_caller_flag(caller_token: str) -> list[TokenInfoWithIsCaller]: """Get the tokens info""" - caller_name = get_token_name(caller_token) - tokens = get_tokens_info() + caller_name = TOKEN_REPO.get_token_by_token_string(caller_token).device_name + tokens = TOKEN_REPO.get_tokens() return [ TokenInfoWithIsCaller( - name=token.name, - date=token.date, - is_caller=token.name == caller_name, + name=token.device_name, + date=token.created_at, + is_caller=token.device_name == caller_name, ) for token in tokens ] +def is_token_valid(token) -> bool: + """Check if token is valid""" + return TOKEN_REPO.is_token_valid(token) + + class NotFoundException(Exception): """Not found exception""" @@ -50,19 +54,22 @@ class CannotDeleteCallerException(Exception): def delete_api_token(caller_token: str, token_name: str) -> None: """Delete the token""" - if is_token_name_pair_valid(token_name, caller_token): + if TOKEN_REPO.is_token_name_pair_valid(token_name, caller_token): raise CannotDeleteCallerException("Cannot delete caller's token") - if not is_token_name_exists(token_name): + if not TOKEN_REPO.is_token_name_exists(token_name): raise NotFoundException("Token not found") - delete_token(token_name) + token = TOKEN_REPO.get_token_by_name(token_name) + TOKEN_REPO.delete_token(token) def refresh_api_token(caller_token: str) -> str: """Refresh the token""" - new_token = refresh_token(caller_token) - if new_token is None: + try: + old_token = TOKEN_REPO.get_token_by_token_string(caller_token) + new_token = TOKEN_REPO.refresh_token(old_token) + except TokenNotFound: raise NotFoundException("Token not found") - return new_token + return new_token.token class RecoveryTokenStatus(BaseModel): @@ -77,18 +84,16 @@ class RecoveryTokenStatus(BaseModel): def get_api_recovery_token_status() -> RecoveryTokenStatus: """Get the recovery token status""" - if not is_recovery_token_exists(): + token = TOKEN_REPO.get_recovery_key() + if token is None: return RecoveryTokenStatus(exists=False, valid=False) - status = get_recovery_token_status() - if status is None: - return RecoveryTokenStatus(exists=False, valid=False) - is_valid = is_recovery_token_valid() + is_valid = TOKEN_REPO.is_recovery_key_valid() return RecoveryTokenStatus( exists=True, valid=is_valid, - date=status["date"], - expiration=status["expiration"], - uses_left=status["uses_left"], + date=token.created_at, + expiration=token.expires_at, + uses_left=token.uses_left, ) @@ -112,5 +117,46 @@ def get_new_api_recovery_key( if uses_left <= 0: raise InvalidUsesLeft("Uses must be greater than 0") - key = generate_recovery_token(expiration_date, uses_left) - return key + key = TOKEN_REPO.create_recovery_key(expiration_date, uses_left) + mnemonic_phrase = Mnemonic(language="english").to_mnemonic(bytes.fromhex(key.key)) + return mnemonic_phrase + + +def use_mnemonic_recovery_token(mnemonic_phrase, name): + """Use the recovery token by converting the mnemonic word list to a byte array. + If the recovery token if invalid itself, return None + If the binary representation of phrase not matches + the byte array of the recovery token, return None. + If the mnemonic phrase is valid then generate a device token and return it. + Substract 1 from uses_left if it exists. + mnemonic_phrase is a string representation of the mnemonic word list. + """ + try: + token = TOKEN_REPO.use_mnemonic_recovery_key(mnemonic_phrase, name) + return token.token + except (RecoveryKeyNotFound, InvalidMnemonic): + return None + + +def delete_new_device_auth_token() -> None: + TOKEN_REPO.delete_new_device_key() + + +def get_new_device_auth_token() -> str: + """Generate and store a new device auth token which is valid for 10 minutes + and return a mnemonic phrase representation + """ + key = TOKEN_REPO.get_new_device_key() + return Mnemonic(language="english").to_mnemonic(bytes.fromhex(key.key)) + + +def use_new_device_auth_token(mnemonic_phrase, name) -> Optional[str]: + """Use the new device auth token by converting the mnemonic string to a byte array. + If the mnemonic phrase is valid then generate a device token and return it. + New device auth token must be deleted. + """ + try: + token = TOKEN_REPO.use_mnemonic_new_device_key(mnemonic_phrase, name) + return token.token + except (NewDeviceKeyNotFound, InvalidMnemonic): + return None diff --git a/selfprivacy_api/dependencies.py b/selfprivacy_api/dependencies.py index 9568a40..3284fd8 100644 --- a/selfprivacy_api/dependencies.py +++ b/selfprivacy_api/dependencies.py @@ -2,7 +2,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import APIKeyHeader from pydantic import BaseModel -from selfprivacy_api.utils.auth import is_token_valid +from selfprivacy_api.actions.api_tokens import is_token_valid class TokenHeader(BaseModel): @@ -27,4 +27,4 @@ async def get_token_header( def get_api_version() -> str: """Get API version""" - return "2.0.9" + return "2.1.2" diff --git a/selfprivacy_api/graphql/__init__.py b/selfprivacy_api/graphql/__init__.py index 7372197..6124a1a 100644 --- a/selfprivacy_api/graphql/__init__.py +++ b/selfprivacy_api/graphql/__init__.py @@ -4,7 +4,7 @@ import typing from strawberry.permission import BasePermission from strawberry.types import Info -from selfprivacy_api.utils.auth import is_token_valid +from selfprivacy_api.actions.api_tokens import is_token_valid class IsAuthenticated(BasePermission): diff --git a/selfprivacy_api/graphql/common_types/jobs.py b/selfprivacy_api/graphql/common_types/jobs.py index 4b095c8..3019a70 100644 --- a/selfprivacy_api/graphql/common_types/jobs.py +++ b/selfprivacy_api/graphql/common_types/jobs.py @@ -43,7 +43,7 @@ def job_to_api_job(job: Job) -> ApiJob: def get_api_job_by_id(job_id: str) -> typing.Optional[ApiJob]: """Get a job for GraphQL by its ID.""" - job = Jobs.get_instance().get_job(job_id) + job = Jobs.get_job(job_id) if job is None: return None return job_to_api_job(job) diff --git a/selfprivacy_api/graphql/mutations/api_mutations.py b/selfprivacy_api/graphql/mutations/api_mutations.py index c6727db..49c49ad 100644 --- a/selfprivacy_api/graphql/mutations/api_mutations.py +++ b/selfprivacy_api/graphql/mutations/api_mutations.py @@ -11,6 +11,11 @@ from selfprivacy_api.actions.api_tokens import ( NotFoundException, delete_api_token, get_new_api_recovery_key, + use_mnemonic_recovery_token, + refresh_api_token, + delete_new_device_auth_token, + get_new_device_auth_token, + use_new_device_auth_token, ) from selfprivacy_api.graphql import IsAuthenticated from selfprivacy_api.graphql.mutations.mutation_interface import ( @@ -18,14 +23,6 @@ from selfprivacy_api.graphql.mutations.mutation_interface import ( MutationReturnInterface, ) -from selfprivacy_api.utils.auth import ( - delete_new_device_auth_token, - get_new_device_auth_token, - refresh_token, - use_mnemonic_recoverery_token, - use_new_device_auth_token, -) - @strawberry.type class ApiKeyMutationReturn(MutationReturnInterface): @@ -98,50 +95,53 @@ class ApiMutations: self, input: UseRecoveryKeyInput ) -> DeviceApiTokenMutationReturn: """Use recovery key""" - token = use_mnemonic_recoverery_token(input.key, input.deviceName) - if token is None: + token = use_mnemonic_recovery_token(input.key, input.deviceName) + if token is not None: + return DeviceApiTokenMutationReturn( + success=True, + message="Recovery key used", + code=200, + token=token, + ) + else: return DeviceApiTokenMutationReturn( success=False, message="Recovery key not found", code=404, token=None, ) - return DeviceApiTokenMutationReturn( - success=True, - message="Recovery key used", - code=200, - token=token, - ) @strawberry.mutation(permission_classes=[IsAuthenticated]) def refresh_device_api_token(self, info: Info) -> DeviceApiTokenMutationReturn: """Refresh device api token""" - token = ( + token_string = ( info.context["request"] .headers.get("Authorization", "") .replace("Bearer ", "") ) - if token is None: + if token_string is None: return DeviceApiTokenMutationReturn( success=False, message="Token not found", code=404, token=None, ) - new_token = refresh_token(token) - if new_token is None: + + try: + new_token = refresh_api_token(token_string) + return DeviceApiTokenMutationReturn( + success=True, + message="Token refreshed", + code=200, + token=new_token, + ) + except NotFoundException: return DeviceApiTokenMutationReturn( success=False, message="Token not found", code=404, token=None, ) - return DeviceApiTokenMutationReturn( - success=True, - message="Token refreshed", - code=200, - token=new_token, - ) @strawberry.mutation(permission_classes=[IsAuthenticated]) def delete_device_api_token(self, device: str, info: Info) -> GenericMutationReturn: diff --git a/selfprivacy_api/graphql/mutations/job_mutations.py b/selfprivacy_api/graphql/mutations/job_mutations.py index 1ac2447..acc5f3d 100644 --- a/selfprivacy_api/graphql/mutations/job_mutations.py +++ b/selfprivacy_api/graphql/mutations/job_mutations.py @@ -14,7 +14,7 @@ class JobMutations: @strawberry.mutation(permission_classes=[IsAuthenticated]) def remove_job(self, job_id: str) -> GenericMutationReturn: """Remove a job from the queue""" - result = Jobs.get_instance().remove_by_uid(job_id) + result = Jobs.remove_by_uid(job_id) if result: return GenericMutationReturn( success=True, diff --git a/selfprivacy_api/graphql/queries/api_queries.py b/selfprivacy_api/graphql/queries/api_queries.py index 7994a8f..cf56231 100644 --- a/selfprivacy_api/graphql/queries/api_queries.py +++ b/selfprivacy_api/graphql/queries/api_queries.py @@ -4,16 +4,12 @@ import datetime import typing import strawberry from strawberry.types import Info -from selfprivacy_api.actions.api_tokens import get_api_tokens_with_caller_flag -from selfprivacy_api.graphql import IsAuthenticated -from selfprivacy_api.utils import parse_date -from selfprivacy_api.dependencies import get_api_version as get_api_version_dependency - -from selfprivacy_api.utils.auth import ( - get_recovery_token_status, - is_recovery_token_exists, - is_recovery_token_valid, +from selfprivacy_api.actions.api_tokens import ( + get_api_tokens_with_caller_flag, + get_api_recovery_token_status, ) +from selfprivacy_api.graphql import IsAuthenticated +from selfprivacy_api.dependencies import get_api_version as get_api_version_dependency def get_api_version() -> str: @@ -43,16 +39,8 @@ class ApiRecoveryKeyStatus: 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, - ) - status = get_recovery_token_status() - if status is None: + status = get_api_recovery_token_status() + if status is None or not status.exists: return ApiRecoveryKeyStatus( exists=False, valid=False, @@ -62,12 +50,10 @@ def get_recovery_key_status() -> ApiRecoveryKeyStatus: ) 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, - uses_left=status["uses_left"] if status["uses_left"] is not None else None, + valid=status.valid, + creation_date=status.date, + expiration_date=status.expiration, + uses_left=status.uses_left, ) diff --git a/selfprivacy_api/graphql/queries/jobs.py b/selfprivacy_api/graphql/queries/jobs.py index 426c563..49bcbd7 100644 --- a/selfprivacy_api/graphql/queries/jobs.py +++ b/selfprivacy_api/graphql/queries/jobs.py @@ -16,9 +16,9 @@ class Job: @strawberry.field def get_jobs(self) -> typing.List[ApiJob]: - Jobs.get_instance().get_jobs() + Jobs.get_jobs() - return [job_to_api_job(job) for job in Jobs.get_instance().get_jobs()] + return [job_to_api_job(job) for job in Jobs.get_jobs()] @strawberry.field def get_job(self, job_id: str) -> typing.Optional[ApiJob]: diff --git a/selfprivacy_api/graphql/queries/providers.py b/selfprivacy_api/graphql/queries/providers.py index 6d0381e..5583c4e 100644 --- a/selfprivacy_api/graphql/queries/providers.py +++ b/selfprivacy_api/graphql/queries/providers.py @@ -6,8 +6,15 @@ import strawberry @strawberry.enum class DnsProvider(Enum): CLOUDFLARE = "CLOUDFLARE" + DIGITALOCEAN = "DIGITALOCEAN" @strawberry.enum class ServerProvider(Enum): HETZNER = "HETZNER" + DIGITALOCEAN = "DIGITALOCEAN" + + +@strawberry.enum +class BackupProvider(Enum): + BACKBLAZE = "BACKBLAZE" diff --git a/selfprivacy_api/graphql/queries/system.py b/selfprivacy_api/graphql/queries/system.py index 0e2a7ec..cc30fd7 100644 --- a/selfprivacy_api/graphql/queries/system.py +++ b/selfprivacy_api/graphql/queries/system.py @@ -44,7 +44,7 @@ def get_system_domain_info() -> SystemDomainInfo: return SystemDomainInfo( domain=user_data["domain"], hostname=user_data["hostname"], - provider=DnsProvider.CLOUDFLARE, + provider=user_data["dns"]["provider"], ) @@ -133,7 +133,11 @@ class SystemProviderInfo: def get_system_provider_info() -> SystemProviderInfo: """Get system provider info""" - return SystemProviderInfo(provider=ServerProvider.HETZNER, id="UNKNOWN") + with ReadUserData() as user_data: + return SystemProviderInfo( + provider=user_data["server"]["provider"], + id="UNKNOWN", + ) @strawberry.type diff --git a/selfprivacy_api/jobs/__init__.py b/selfprivacy_api/jobs/__init__.py index f30fc5b..fe4a053 100644 --- a/selfprivacy_api/jobs/__init__.py +++ b/selfprivacy_api/jobs/__init__.py @@ -17,16 +17,14 @@ A job is a dictionary with the following keys: import typing import datetime from uuid import UUID -import asyncio -import json -import os -import time import uuid from enum import Enum from pydantic import BaseModel -from selfprivacy_api.utils import ReadUserData, UserDataFiles, WriteUserData +from selfprivacy_api.utils.redis_pool import RedisPool + +JOB_EXPIRATION_SECONDS = 10 * 24 * 60 * 60 # ten days class JobStatus(Enum): @@ -64,36 +62,14 @@ class Jobs: Jobs class. """ - __instance = None - - @staticmethod - def get_instance(): - """ - Singleton method. - """ - if Jobs.__instance is None: - Jobs() - if Jobs.__instance is None: - raise Exception("Couldn't init Jobs singleton!") - return Jobs.__instance - return Jobs.__instance - - def __init__(self): - """ - Initialize the jobs list. - """ - if Jobs.__instance is not None: - raise Exception("This class is a singleton!") - else: - Jobs.__instance = self - @staticmethod def reset() -> None: """ Reset the jobs list. """ - with WriteUserData(UserDataFiles.JOBS) as user_data: - user_data["jobs"] = [] + jobs = Jobs.get_jobs() + for job in jobs: + Jobs.remove(job) @staticmethod def add( @@ -121,32 +97,27 @@ class Jobs: error=None, result=None, ) - with WriteUserData(UserDataFiles.JOBS) as user_data: - try: - if "jobs" not in user_data: - user_data["jobs"] = [] - user_data["jobs"].append(json.loads(job.json())) - except json.decoder.JSONDecodeError: - user_data["jobs"] = [json.loads(job.json())] + redis = RedisPool().get_connection() + _store_job_as_hash(redis, _redis_key_from_uuid(job.uid), job) return job - def remove(self, job: Job) -> None: + @staticmethod + def remove(job: Job) -> None: """ Remove a job from the jobs list. """ - self.remove_by_uid(str(job.uid)) + Jobs.remove_by_uid(str(job.uid)) - def remove_by_uid(self, job_uuid: str) -> bool: + @staticmethod + def remove_by_uid(job_uuid: str) -> bool: """ Remove a job from the jobs list. """ - with WriteUserData(UserDataFiles.JOBS) as user_data: - if "jobs" not in user_data: - user_data["jobs"] = [] - for i, j in enumerate(user_data["jobs"]): - if j["uid"] == job_uuid: - del user_data["jobs"][i] - return True + redis = RedisPool().get_connection() + key = _redis_key_from_uuid(job_uuid) + if redis.exists(key): + redis.delete(key) + return True return False @staticmethod @@ -178,13 +149,12 @@ class Jobs: if status in (JobStatus.FINISHED, JobStatus.ERROR): job.finished_at = datetime.datetime.now() - with WriteUserData(UserDataFiles.JOBS) as user_data: - if "jobs" not in user_data: - user_data["jobs"] = [] - for i, j in enumerate(user_data["jobs"]): - if j["uid"] == str(job.uid): - user_data["jobs"][i] = json.loads(job.json()) - break + redis = RedisPool().get_connection() + key = _redis_key_from_uuid(job.uid) + if redis.exists(key): + _store_job_as_hash(redis, key, job) + if status in (JobStatus.FINISHED, JobStatus.ERROR): + redis.expire(key, JOB_EXPIRATION_SECONDS) return job @@ -193,12 +163,10 @@ class Jobs: """ Get a job from the jobs list. """ - with ReadUserData(UserDataFiles.JOBS) as user_data: - if "jobs" not in user_data: - user_data["jobs"] = [] - for job in user_data["jobs"]: - if job["uid"] == uid: - return Job(**job) + redis = RedisPool().get_connection() + key = _redis_key_from_uuid(uid) + if redis.exists(key): + return _job_from_hash(redis, key) return None @staticmethod @@ -206,23 +174,54 @@ class Jobs: """ Get the jobs list. """ - with ReadUserData(UserDataFiles.JOBS) as user_data: - try: - if "jobs" not in user_data: - user_data["jobs"] = [] - return [Job(**job) for job in user_data["jobs"]] - except json.decoder.JSONDecodeError: - return [] + redis = RedisPool().get_connection() + job_keys = redis.keys("jobs:*") + jobs = [] + for job_key in job_keys: + job = _job_from_hash(redis, job_key) + if job is not None: + jobs.append(job) + return jobs @staticmethod def is_busy() -> bool: """ Check if there is a job running. """ - with ReadUserData(UserDataFiles.JOBS) as user_data: - if "jobs" not in user_data: - user_data["jobs"] = [] - for job in user_data["jobs"]: - if job["status"] == JobStatus.RUNNING.value: - return True + for job in Jobs.get_jobs(): + if job.status == JobStatus.RUNNING: + return True return False + + +def _redis_key_from_uuid(uuid_string): + return "jobs:" + str(uuid_string) + + +def _store_job_as_hash(redis, redis_key, model): + for key, value in model.dict().items(): + if isinstance(value, uuid.UUID): + value = str(value) + if isinstance(value, datetime.datetime): + value = value.isoformat() + if isinstance(value, JobStatus): + value = value.value + redis.hset(redis_key, key, str(value)) + + +def _job_from_hash(redis, redis_key): + if redis.exists(redis_key): + job_dict = redis.hgetall(redis_key) + for date in [ + "created_at", + "updated_at", + "finished_at", + ]: + if job_dict[date] != "None": + job_dict[date] = datetime.datetime.fromisoformat(job_dict[date]) + for key in job_dict.keys(): + if job_dict[key] == "None": + job_dict[key] = None + + return Job(**job_dict) + return None diff --git a/selfprivacy_api/jobs/test.py b/selfprivacy_api/jobs/test.py index 9d93fb7..e3c38f4 100644 --- a/selfprivacy_api/jobs/test.py +++ b/selfprivacy_api/jobs/test.py @@ -5,7 +5,7 @@ from selfprivacy_api.jobs import JobStatus, Jobs @huey.task() def test_job(): - job = Jobs.get_instance().add( + job = Jobs.add( type_id="test", name="Test job", description="This is a test job.", @@ -14,42 +14,42 @@ def test_job(): progress=0, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text="Performing pre-move checks...", progress=5, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text="Performing pre-move checks...", progress=10, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text="Performing pre-move checks...", progress=15, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text="Performing pre-move checks...", progress=20, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text="Performing pre-move checks...", progress=25, ) time.sleep(5) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.FINISHED, status_text="Job finished.", diff --git a/selfprivacy_api/migrations/__init__.py b/selfprivacy_api/migrations/__init__.py index b051f04..adb7d24 100644 --- a/selfprivacy_api/migrations/__init__.py +++ b/selfprivacy_api/migrations/__init__.py @@ -18,6 +18,10 @@ from selfprivacy_api.migrations.migrate_to_selfprivacy_channel import ( MigrateToSelfprivacyChannel, ) from selfprivacy_api.migrations.mount_volume import MountVolume +from selfprivacy_api.migrations.providers import CreateProviderFields +from selfprivacy_api.migrations.prepare_for_nixos_2211 import ( + MigrateToSelfprivacyChannelFrom2205, +) migrations = [ FixNixosConfigBranch(), @@ -25,6 +29,8 @@ migrations = [ MigrateToSelfprivacyChannel(), MountVolume(), CheckForFailedBindsMigration(), + CreateProviderFields(), + MigrateToSelfprivacyChannelFrom2205(), ] diff --git a/selfprivacy_api/migrations/check_for_failed_binds_migration.py b/selfprivacy_api/migrations/check_for_failed_binds_migration.py index 5871809..41d56b2 100644 --- a/selfprivacy_api/migrations/check_for_failed_binds_migration.py +++ b/selfprivacy_api/migrations/check_for_failed_binds_migration.py @@ -15,7 +15,7 @@ class CheckForFailedBindsMigration(Migration): def is_migration_needed(self): try: - jobs = Jobs.get_instance().get_jobs() + jobs = Jobs.get_jobs() # If there is a job with type_id "migrations.migrate_to_binds" and status is not "FINISHED", # then migration is needed and job is deleted for job in jobs: @@ -33,13 +33,13 @@ class CheckForFailedBindsMigration(Migration): # Get info about existing volumes # Write info about volumes to userdata.json try: - jobs = Jobs.get_instance().get_jobs() + jobs = Jobs.get_jobs() for job in jobs: if ( job.type_id == "migrations.migrate_to_binds" and job.status != JobStatus.FINISHED ): - Jobs.get_instance().remove(job) + Jobs.remove(job) with WriteUserData() as userdata: userdata["useBinds"] = False print("Done") diff --git a/selfprivacy_api/migrations/prepare_for_nixos_2211.py b/selfprivacy_api/migrations/prepare_for_nixos_2211.py new file mode 100644 index 0000000..849c262 --- /dev/null +++ b/selfprivacy_api/migrations/prepare_for_nixos_2211.py @@ -0,0 +1,58 @@ +import os +import subprocess + +from selfprivacy_api.migrations.migration import Migration + + +class MigrateToSelfprivacyChannelFrom2205(Migration): + """Migrate to selfprivacy Nix channel. + For some reason NixOS 22.05 servers initialized with the nixos channel instead of selfprivacy. + This stops us from upgrading to NixOS 22.11 + """ + + def get_migration_name(self): + return "migrate_to_selfprivacy_channel_from_2205" + + def get_migration_description(self): + return "Migrate to selfprivacy Nix channel from NixOS 22.05." + + def is_migration_needed(self): + try: + output = subprocess.check_output( + ["nix-channel", "--list"], start_new_session=True + ) + output = output.decode("utf-8") + first_line = output.split("\n", maxsplit=1)[0] + return first_line.startswith("nixos") and ( + first_line.endswith("nixos-22.05") + ) + except subprocess.CalledProcessError: + return False + + def migrate(self): + # Change the channel and update them. + # Also, go to /etc/nixos directory and make a git pull + current_working_directory = os.getcwd() + try: + print("Changing channel") + os.chdir("/etc/nixos") + subprocess.check_output( + [ + "nix-channel", + "--add", + "https://channel.selfprivacy.org/nixos-selfpricacy", + "nixos", + ] + ) + subprocess.check_output(["nix-channel", "--update"]) + nixos_config_branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], start_new_session=True + ) + if nixos_config_branch.decode("utf-8").strip() == "api-redis": + print("Also changing nixos-config branch from api-redis to master") + subprocess.check_output(["git", "checkout", "master"]) + subprocess.check_output(["git", "pull"]) + os.chdir(current_working_directory) + except subprocess.CalledProcessError: + os.chdir(current_working_directory) + print("Error") diff --git a/selfprivacy_api/migrations/providers.py b/selfprivacy_api/migrations/providers.py new file mode 100644 index 0000000..2cd5d5e --- /dev/null +++ b/selfprivacy_api/migrations/providers.py @@ -0,0 +1,43 @@ +from selfprivacy_api.migrations.migration import Migration +from selfprivacy_api.utils import ReadUserData, WriteUserData + + +class CreateProviderFields(Migration): + """Unhardcode providers""" + + def get_migration_name(self): + return "create_provider_fields" + + def get_migration_description(self): + return "Add DNS, backup and server provider fields to enable user to choose between different clouds and to make the deployment adapt to these preferences." + + def is_migration_needed(self): + try: + with ReadUserData() as userdata: + return "dns" not in userdata + except Exception as e: + print(e) + return False + + def migrate(self): + # Write info about providers to userdata.json + try: + with WriteUserData() as userdata: + userdata["dns"] = { + "provider": "CLOUDFLARE", + "apiKey": userdata["cloudflare"]["apiKey"], + } + userdata["server"] = { + "provider": "HETZNER", + } + userdata["backup"] = { + "provider": "BACKBLAZE", + "accountId": userdata["backblaze"]["accountId"], + "accountKey": userdata["backblaze"]["accountKey"], + "bucket": userdata["backblaze"]["bucket"], + } + + print("Done") + except Exception as e: + print(e) + print("Error migrating provider fields") diff --git a/selfprivacy_api/models/tokens/__init__.py b/selfprivacy_api/models/tokens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/selfprivacy_api/repositories/tokens/abstract_tokens_repository.py b/selfprivacy_api/repositories/tokens/abstract_tokens_repository.py index 3cf6e1d..3a20ede 100644 --- a/selfprivacy_api/repositories/tokens/abstract_tokens_repository.py +++ b/selfprivacy_api/repositories/tokens/abstract_tokens_repository.py @@ -1,55 +1,84 @@ from abc import ABC, abstractmethod from datetime import datetime from typing import Optional +from mnemonic import Mnemonic +from secrets import randbelow +import re from selfprivacy_api.models.tokens.token import Token +from selfprivacy_api.repositories.tokens.exceptions import ( + TokenNotFound, + InvalidMnemonic, + RecoveryKeyNotFound, + NewDeviceKeyNotFound, +) from selfprivacy_api.models.tokens.recovery_key import RecoveryKey from selfprivacy_api.models.tokens.new_device_key import NewDeviceKey class AbstractTokensRepository(ABC): - @abstractmethod - def get_token_by_token_string(self, token_string: str) -> Optional[Token]: + def get_token_by_token_string(self, token_string: str) -> Token: """Get the token by token""" + tokens = self.get_tokens() + for token in tokens: + if token.token == token_string: + return token - @abstractmethod - def get_token_by_name(self, token_name: str) -> Optional[Token]: + raise TokenNotFound("Token not found!") + + def get_token_by_name(self, token_name: str) -> Token: """Get the token by name""" + tokens = self.get_tokens() + for token in tokens: + if token.device_name == token_name: + return token + + raise TokenNotFound("Token not found!") @abstractmethod def get_tokens(self) -> list[Token]: """Get the tokens""" - @abstractmethod def create_token(self, device_name: str) -> Token: """Create new token""" + unique_name = self._make_unique_device_name(device_name) + new_token = Token.generate(unique_name) + + self._store_token(new_token) + + return new_token @abstractmethod def delete_token(self, input_token: Token) -> None: """Delete the token""" - @abstractmethod def refresh_token(self, input_token: Token) -> Token: - """Refresh the token""" + """Change the token field of the existing token""" + new_token = Token.generate(device_name=input_token.device_name) + new_token.created_at = input_token.created_at + + if input_token in self.get_tokens(): + self.delete_token(input_token) + self._store_token(new_token) + return new_token + + raise TokenNotFound("Token not found!") def is_token_valid(self, token_string: str) -> bool: """Check if the token is valid""" - token = self.get_token_by_token_string(token_string) - if token is None: - return False - return True + return token_string in [token.token for token in self.get_tokens()] def is_token_name_exists(self, token_name: str) -> bool: """Check if the token name exists""" - token = self.get_token_by_name(token_name) - if token is None: - return False - return True + return token_name in [token.device_name for token in self.get_tokens()] def is_token_name_pair_valid(self, token_name: str, token_string: str) -> bool: """Check if the token name and token are valid""" - token = self.get_token_by_name(token_name) - if token is None: + try: + token = self.get_token_by_name(token_name) + if token is None: + return False + except TokenNotFound: return False return token.token == token_string @@ -65,11 +94,27 @@ class AbstractTokensRepository(ABC): ) -> RecoveryKey: """Create the recovery key""" - @abstractmethod def use_mnemonic_recovery_key( self, mnemonic_phrase: str, device_name: str ) -> Token: """Use the mnemonic recovery key and create a new token with the given name""" + if not self.is_recovery_key_valid(): + raise RecoveryKeyNotFound("Recovery key not found") + + recovery_key = self.get_recovery_key() + + if recovery_key is None: + raise RecoveryKeyNotFound("Recovery key not found") + + recovery_hex_key = recovery_key.key + if not self._assert_mnemonic(recovery_hex_key, mnemonic_phrase): + raise RecoveryKeyNotFound("Recovery key not found") + + new_token = self.create_token(device_name=device_name) + + self._decrement_recovery_token() + + return new_token def is_recovery_key_valid(self) -> bool: """Check if the recovery key is valid""" @@ -78,16 +123,71 @@ class AbstractTokensRepository(ABC): return False return recovery_key.is_valid() - @abstractmethod def get_new_device_key(self) -> NewDeviceKey: """Creates and returns the new device key""" + new_device_key = NewDeviceKey.generate() + self._store_new_device_key(new_device_key) + + return new_device_key + + def _store_new_device_key(self, new_device_key: NewDeviceKey) -> None: + """Store new device key directly""" @abstractmethod def delete_new_device_key(self) -> None: """Delete the new device key""" - @abstractmethod def use_mnemonic_new_device_key( self, mnemonic_phrase: str, device_name: str ) -> Token: """Use the mnemonic new device key""" + new_device_key = self._get_stored_new_device_key() + if not new_device_key: + raise NewDeviceKeyNotFound + + if not new_device_key.is_valid(): + raise NewDeviceKeyNotFound + + if not self._assert_mnemonic(new_device_key.key, mnemonic_phrase): + raise NewDeviceKeyNotFound("Phrase is not token!") + + new_token = self.create_token(device_name=device_name) + self.delete_new_device_key() + + return new_token + + @abstractmethod + def _store_token(self, new_token: Token): + """Store a token directly""" + + @abstractmethod + def _decrement_recovery_token(self): + """Decrement recovery key use count by one""" + + @abstractmethod + def _get_stored_new_device_key(self) -> Optional[NewDeviceKey]: + """Retrieves new device key that is already stored.""" + + def _make_unique_device_name(self, name: str) -> str: + """Token name must be an alphanumeric string and not empty. + Replace invalid characters with '_' + If name exists, add a random number to the end of the name until it is unique. + """ + if not re.match("^[a-zA-Z0-9]*$", name): + name = re.sub("[^a-zA-Z0-9]", "_", name) + if name == "": + name = "Unknown device" + while self.is_token_name_exists(name): + name += str(randbelow(10)) + return name + + # TODO: find a proper place for it + def _assert_mnemonic(self, hex_key: str, mnemonic_phrase: str): + """Return true if hex string matches the phrase, false otherwise + Raise an InvalidMnemonic error if not mnemonic""" + recovery_token = bytes.fromhex(hex_key) + if not Mnemonic(language="english").check(mnemonic_phrase): + raise InvalidMnemonic("Phrase is not mnemonic!") + + phrase_bytes = Mnemonic(language="english").to_entropy(mnemonic_phrase) + return phrase_bytes == recovery_token diff --git a/selfprivacy_api/repositories/tokens/json_tokens_repository.py b/selfprivacy_api/repositories/tokens/json_tokens_repository.py index aad3158..77e1311 100644 --- a/selfprivacy_api/repositories/tokens/json_tokens_repository.py +++ b/selfprivacy_api/repositories/tokens/json_tokens_repository.py @@ -3,7 +3,6 @@ temporary legacy """ from typing import Optional from datetime import datetime -from mnemonic import Mnemonic from selfprivacy_api.utils import UserDataFiles, WriteUserData, ReadUserData from selfprivacy_api.models.tokens.token import Token @@ -11,9 +10,6 @@ from selfprivacy_api.models.tokens.recovery_key import RecoveryKey from selfprivacy_api.models.tokens.new_device_key import NewDeviceKey from selfprivacy_api.repositories.tokens.exceptions import ( TokenNotFound, - RecoveryKeyNotFound, - InvalidMnemonic, - NewDeviceKeyNotFound, ) from selfprivacy_api.repositories.tokens.abstract_tokens_repository import ( AbstractTokensRepository, @@ -23,34 +19,6 @@ DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" class JsonTokensRepository(AbstractTokensRepository): - def get_token_by_token_string(self, token_string: str) -> Optional[Token]: - """Get the token by token""" - with ReadUserData(UserDataFiles.TOKENS) as tokens_file: - for userdata_token in tokens_file["tokens"]: - if userdata_token["token"] == token_string: - - return Token( - token=token_string, - device_name=userdata_token["name"], - created_at=userdata_token["date"], - ) - - raise TokenNotFound("Token not found!") - - def get_token_by_name(self, token_name: str) -> Optional[Token]: - """Get the token by name""" - with ReadUserData(UserDataFiles.TOKENS) as tokens_file: - for userdata_token in tokens_file["tokens"]: - if userdata_token["name"] == token_name: - - return Token( - token=userdata_token["token"], - device_name=token_name, - created_at=userdata_token["date"], - ) - - raise TokenNotFound("Token not found!") - def get_tokens(self) -> list[Token]: """Get the tokens""" tokens_list = [] @@ -67,10 +35,8 @@ class JsonTokensRepository(AbstractTokensRepository): return tokens_list - def create_token(self, device_name: str) -> Token: - """Create new token""" - new_token = Token.generate(device_name) - + def _store_token(self, new_token: Token): + """Store a token directly""" with WriteUserData(UserDataFiles.TOKENS) as tokens_file: tokens_file["tokens"].append( { @@ -79,7 +45,6 @@ class JsonTokensRepository(AbstractTokensRepository): "date": new_token.created_at.strftime(DATETIME_FORMAT), } ) - return new_token def delete_token(self, input_token: Token) -> None: """Delete the token""" @@ -91,23 +56,6 @@ class JsonTokensRepository(AbstractTokensRepository): raise TokenNotFound("Token not found!") - def refresh_token(self, input_token: Token) -> Token: - """Change the token field of the existing token""" - new_token = Token.generate(device_name=input_token.device_name) - - with WriteUserData(UserDataFiles.TOKENS) as tokens_file: - for userdata_token in tokens_file["tokens"]: - - if userdata_token["name"] == input_token.device_name: - userdata_token["token"] = new_token.token - userdata_token["date"] = ( - new_token.created_at.strftime(DATETIME_FORMAT), - ) - - return new_token - - raise TokenNotFound("Token not found!") - def get_recovery_key(self) -> Optional[RecoveryKey]: """Get the recovery key""" with ReadUserData(UserDataFiles.TOKENS) as tokens_file: @@ -121,7 +69,7 @@ class JsonTokensRepository(AbstractTokensRepository): recovery_key = RecoveryKey( key=tokens_file["recovery_token"].get("token"), created_at=tokens_file["recovery_token"].get("date"), - expires_at=tokens_file["recovery_token"].get("expitation"), + expires_at=tokens_file["recovery_token"].get("expiration"), uses_left=tokens_file["recovery_token"].get("uses_left"), ) @@ -137,59 +85,26 @@ class JsonTokensRepository(AbstractTokensRepository): recovery_key = RecoveryKey.generate(expiration, uses_left) with WriteUserData(UserDataFiles.TOKENS) as tokens_file: + key_expiration: Optional[str] = None + if recovery_key.expires_at is not None: + key_expiration = recovery_key.expires_at.strftime(DATETIME_FORMAT) tokens_file["recovery_token"] = { "token": recovery_key.key, "date": recovery_key.created_at.strftime(DATETIME_FORMAT), - "expiration": recovery_key.expires_at, + "expiration": key_expiration, "uses_left": recovery_key.uses_left, } return recovery_key - def use_mnemonic_recovery_key( - self, mnemonic_phrase: str, device_name: str - ) -> Token: - """Use the mnemonic recovery key and create a new token with the given name""" - recovery_key = self.get_recovery_key() - - if recovery_key is None: - raise RecoveryKeyNotFound("Recovery key not found") - - if not recovery_key.is_valid(): - raise RecoveryKeyNotFound("Recovery key not found") - - recovery_token = bytes.fromhex(recovery_key.key) - - if not Mnemonic(language="english").check(mnemonic_phrase): - raise InvalidMnemonic("Phrase is not mnemonic!") - - phrase_bytes = Mnemonic(language="english").to_entropy(mnemonic_phrase) - if phrase_bytes != recovery_token: - raise RecoveryKeyNotFound("Recovery key not found") - - new_token = Token.generate(device_name=device_name) - - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["tokens"].append( - { - "token": new_token.token, - "name": new_token.device_name, - "date": new_token.created_at.strftime(DATETIME_FORMAT), - } - ) - - if "recovery_token" in tokens: - if ( - "uses_left" in tokens["recovery_token"] - and tokens["recovery_token"]["uses_left"] is not None - ): + def _decrement_recovery_token(self): + """Decrement recovery key use count by one""" + if self.is_recovery_key_valid(): + with WriteUserData(UserDataFiles.TOKENS) as tokens: + if tokens["recovery_token"]["uses_left"] is not None: tokens["recovery_token"]["uses_left"] -= 1 - return new_token - - def get_new_device_key(self) -> NewDeviceKey: - """Creates and returns the new device key""" - new_device_key = NewDeviceKey.generate() + def _store_new_device_key(self, new_device_key: NewDeviceKey) -> None: with WriteUserData(UserDataFiles.TOKENS) as tokens_file: tokens_file["new_device"] = { "token": new_device_key.key, @@ -197,8 +112,6 @@ class JsonTokensRepository(AbstractTokensRepository): "expiration": new_device_key.expires_at.strftime(DATETIME_FORMAT), } - return new_device_key - def delete_new_device_key(self) -> None: """Delete the new device key""" with WriteUserData(UserDataFiles.TOKENS) as tokens_file: @@ -206,33 +119,15 @@ class JsonTokensRepository(AbstractTokensRepository): del tokens_file["new_device"] return - def use_mnemonic_new_device_key( - self, mnemonic_phrase: str, device_name: str - ) -> Token: - """Use the mnemonic new device key""" - + def _get_stored_new_device_key(self) -> Optional[NewDeviceKey]: + """Retrieves new device key that is already stored.""" with ReadUserData(UserDataFiles.TOKENS) as tokens_file: if "new_device" not in tokens_file or tokens_file["new_device"] is None: - raise NewDeviceKeyNotFound("New device key not found") + return new_device_key = NewDeviceKey( key=tokens_file["new_device"]["token"], created_at=tokens_file["new_device"]["date"], expires_at=tokens_file["new_device"]["expiration"], ) - - token = bytes.fromhex(new_device_key.key) - - if not Mnemonic(language="english").check(mnemonic_phrase): - raise InvalidMnemonic("Phrase is not mnemonic!") - - phrase_bytes = Mnemonic(language="english").to_entropy(mnemonic_phrase) - if bytes(phrase_bytes) != bytes(token): - raise NewDeviceKeyNotFound("Phrase is not token!") - - new_token = Token.generate(device_name=device_name) - with WriteUserData(UserDataFiles.TOKENS) as tokens: - if "new_device" in tokens: - del tokens["new_device"] - - return new_token + return new_device_key diff --git a/selfprivacy_api/repositories/tokens/redis_tokens_repository.py b/selfprivacy_api/repositories/tokens/redis_tokens_repository.py index 0186c11..c72e231 100644 --- a/selfprivacy_api/repositories/tokens/redis_tokens_repository.py +++ b/selfprivacy_api/repositories/tokens/redis_tokens_repository.py @@ -1,9 +1,21 @@ """ Token repository using Redis as backend. """ +from typing import Optional +from datetime import datetime + from selfprivacy_api.repositories.tokens.abstract_tokens_repository import ( AbstractTokensRepository, ) +from selfprivacy_api.utils.redis_pool import RedisPool +from selfprivacy_api.models.tokens.token import Token +from selfprivacy_api.models.tokens.recovery_key import RecoveryKey +from selfprivacy_api.models.tokens.new_device_key import NewDeviceKey +from selfprivacy_api.repositories.tokens.exceptions import TokenNotFound + +TOKENS_PREFIX = "token_repo:tokens:" +NEW_DEVICE_KEY_REDIS_KEY = "token_repo:new_device_key" +RECOVERY_KEY_REDIS_KEY = "token_repo:recovery_key" class RedisTokensRepository(AbstractTokensRepository): @@ -11,5 +23,132 @@ class RedisTokensRepository(AbstractTokensRepository): Token repository using Redis as a backend """ - def __init__(self) -> None: - raise NotImplementedError + def __init__(self): + self.connection = RedisPool().get_connection() + + @staticmethod + def token_key_for_device(device_name: str): + return TOKENS_PREFIX + str(hash(device_name)) + + def get_tokens(self) -> list[Token]: + """Get the tokens""" + redis = self.connection + token_keys = redis.keys(TOKENS_PREFIX + "*") + tokens = [] + for key in token_keys: + token = self._token_from_hash(key) + if token is not None: + tokens.append(token) + return tokens + + def delete_token(self, input_token: Token) -> None: + """Delete the token""" + redis = self.connection + key = RedisTokensRepository._token_redis_key(input_token) + if input_token not in self.get_tokens(): + raise TokenNotFound + redis.delete(key) + + def reset(self): + for token in self.get_tokens(): + self.delete_token(token) + self.delete_new_device_key() + redis = self.connection + redis.delete(RECOVERY_KEY_REDIS_KEY) + + def get_recovery_key(self) -> Optional[RecoveryKey]: + """Get the recovery key""" + redis = self.connection + if redis.exists(RECOVERY_KEY_REDIS_KEY): + return self._recovery_key_from_hash(RECOVERY_KEY_REDIS_KEY) + return None + + def create_recovery_key( + self, + expiration: Optional[datetime], + uses_left: Optional[int], + ) -> RecoveryKey: + """Create the recovery key""" + recovery_key = RecoveryKey.generate(expiration=expiration, uses_left=uses_left) + self._store_model_as_hash(RECOVERY_KEY_REDIS_KEY, recovery_key) + return recovery_key + + def _store_new_device_key(self, new_device_key: NewDeviceKey) -> None: + """Store new device key directly""" + self._store_model_as_hash(NEW_DEVICE_KEY_REDIS_KEY, new_device_key) + + def delete_new_device_key(self) -> None: + """Delete the new device key""" + redis = self.connection + redis.delete(NEW_DEVICE_KEY_REDIS_KEY) + + @staticmethod + def _token_redis_key(token: Token) -> str: + return RedisTokensRepository.token_key_for_device(token.device_name) + + def _store_token(self, new_token: Token): + """Store a token directly""" + key = RedisTokensRepository._token_redis_key(new_token) + self._store_model_as_hash(key, new_token) + + def _decrement_recovery_token(self): + """Decrement recovery key use count by one""" + if self.is_recovery_key_valid(): + recovery_key = self.get_recovery_key() + if recovery_key is None: + return + uses_left = recovery_key.uses_left + if uses_left is not None: + redis = self.connection + redis.hset(RECOVERY_KEY_REDIS_KEY, "uses_left", uses_left - 1) + + def _get_stored_new_device_key(self) -> Optional[NewDeviceKey]: + """Retrieves new device key that is already stored.""" + return self._new_device_key_from_hash(NEW_DEVICE_KEY_REDIS_KEY) + + @staticmethod + def _is_date_key(key: str): + return key in [ + "created_at", + "expires_at", + ] + + @staticmethod + def _prepare_model_dict(d: dict): + date_keys = [key for key in d.keys() if RedisTokensRepository._is_date_key(key)] + for date in date_keys: + if d[date] != "None": + d[date] = datetime.fromisoformat(d[date]) + for key in d.keys(): + if d[key] == "None": + d[key] = None + + def _model_dict_from_hash(self, redis_key: str) -> Optional[dict]: + redis = self.connection + if redis.exists(redis_key): + token_dict = redis.hgetall(redis_key) + RedisTokensRepository._prepare_model_dict(token_dict) + return token_dict + return None + + def _hash_as_model(self, redis_key: str, model_class): + token_dict = self._model_dict_from_hash(redis_key) + if token_dict is not None: + return model_class(**token_dict) + return None + + def _token_from_hash(self, redis_key: str) -> Optional[Token]: + return self._hash_as_model(redis_key, Token) + + def _recovery_key_from_hash(self, redis_key: str) -> Optional[RecoveryKey]: + return self._hash_as_model(redis_key, RecoveryKey) + + def _new_device_key_from_hash(self, redis_key: str) -> Optional[NewDeviceKey]: + return self._hash_as_model(redis_key, NewDeviceKey) + + def _store_model_as_hash(self, redis_key, model): + redis = self.connection + for key, value in model.dict().items(): + if isinstance(value, datetime): + value = value.isoformat() + redis.hset(redis_key, key, str(value)) diff --git a/selfprivacy_api/rest/api_auth.py b/selfprivacy_api/rest/api_auth.py index f73056c..275dac3 100644 --- a/selfprivacy_api/rest/api_auth.py +++ b/selfprivacy_api/rest/api_auth.py @@ -8,20 +8,18 @@ from selfprivacy_api.actions.api_tokens import ( InvalidUsesLeft, NotFoundException, delete_api_token, + refresh_api_token, get_api_recovery_token_status, get_api_tokens_with_caller_flag, get_new_api_recovery_key, - refresh_api_token, + use_mnemonic_recovery_token, + delete_new_device_auth_token, + get_new_device_auth_token, + use_new_device_auth_token, ) from selfprivacy_api.dependencies import TokenHeader, get_token_header -from selfprivacy_api.utils.auth import ( - delete_new_device_auth_token, - get_new_device_auth_token, - use_mnemonic_recoverery_token, - use_new_device_auth_token, -) router = APIRouter( prefix="/auth", @@ -99,7 +97,7 @@ class UseTokenInput(BaseModel): @router.post("/recovery_token/use") async def rest_use_recovery_token(input: UseTokenInput): - token = use_mnemonic_recoverery_token(input.token, input.device) + token = use_mnemonic_recovery_token(input.token, input.device) if token is None: raise HTTPException(status_code=404, detail="Token not found") return {"token": token} diff --git a/selfprivacy_api/rest/services.py b/selfprivacy_api/rest/services.py index c9d5ff9..317cba0 100644 --- a/selfprivacy_api/rest/services.py +++ b/selfprivacy_api/rest/services.py @@ -117,7 +117,7 @@ async def get_mailserver_dkim(): """Get the DKIM record for the mailserver""" domain = get_domain() - dkim = get_dkim_key(domain) + dkim = get_dkim_key(domain, parse=False) if dkim is None: raise HTTPException(status_code=404, detail="DKIM record not found") dkim = base64.b64encode(dkim.encode("utf-8")).decode("utf-8") @@ -257,24 +257,25 @@ async def restore_restic_backup(backup: BackupRestoreInput): raise HTTPException(status_code=404, detail="Backup not found") -class BackblazeConfigInput(BaseModel): +class BackupConfigInput(BaseModel): accountId: str accountKey: str bucket: str @router.put("/restic/backblaze/config") -async def set_backblaze_config(backblaze_config: BackblazeConfigInput): +async def set_backblaze_config(backup_config: BackupConfigInput): with WriteUserData() as data: - if "backblaze" not in data: - data["backblaze"] = {} - data["backblaze"]["accountId"] = backblaze_config.accountId - data["backblaze"]["accountKey"] = backblaze_config.accountKey - data["backblaze"]["bucket"] = backblaze_config.bucket + if "backup" not in data: + data["backup"] = {} + data["backup"]["provider"] = "BACKBLAZE" + data["backup"]["accountId"] = backup_config.accountId + data["backup"]["accountKey"] = backup_config.accountKey + data["backup"]["bucket"] = backup_config.bucket restic_tasks.update_keys_from_userdata() - return "New Backblaze settings saved" + return "New backup settings saved" @router.post("/ssh/enable") diff --git a/selfprivacy_api/restic_controller/__init__.py b/selfprivacy_api/restic_controller/__init__.py index abb5dc8..b4efba2 100644 --- a/selfprivacy_api/restic_controller/__init__.py +++ b/selfprivacy_api/restic_controller/__init__.py @@ -7,6 +7,7 @@ from threading import Lock from enum import Enum import portalocker from selfprivacy_api.utils import ReadUserData +from selfprivacy_api.utils.singleton_metaclass import SingletonMetaclass class ResticStates(Enum): @@ -21,7 +22,7 @@ class ResticStates(Enum): INITIALIZING = 6 -class ResticController: +class ResticController(metaclass=SingletonMetaclass): """ States in wich the restic_controller may be - no backblaze key @@ -35,16 +36,8 @@ class ResticController: Current state can be fetched with get_state() """ - _instance = None - _lock = Lock() _initialized = False - def __new__(cls): - if not cls._instance: - with cls._lock: - cls._instance = super(ResticController, cls).__new__(cls) - return cls._instance - def __init__(self): if self._initialized: return diff --git a/selfprivacy_api/services/bitwarden/__init__.py b/selfprivacy_api/services/bitwarden/__init__.py index ea93de1..16d7746 100644 --- a/selfprivacy_api/services/bitwarden/__init__.py +++ b/selfprivacy_api/services/bitwarden/__init__.py @@ -144,7 +144,7 @@ class Bitwarden(Service): ] def move_to_volume(self, volume: BlockDevice) -> Job: - job = Jobs.get_instance().add( + job = Jobs.add( type_id="services.bitwarden.move", name="Move Bitwarden", description=f"Moving Bitwarden data to {volume.name}", diff --git a/selfprivacy_api/services/generic_service_mover.py b/selfprivacy_api/services/generic_service_mover.py index 8b3a759..6c1b426 100644 --- a/selfprivacy_api/services/generic_service_mover.py +++ b/selfprivacy_api/services/generic_service_mover.py @@ -29,7 +29,7 @@ def move_service( userdata_location: str, ): """Move a service to another volume.""" - job = Jobs.get_instance().update( + job = Jobs.update( job=job, status_text="Performing pre-move checks...", status=JobStatus.RUNNING, @@ -37,7 +37,7 @@ def move_service( service_name = service.get_display_name() with ReadUserData() as user_data: if not user_data.get("useBinds", False): - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error="Server is not using binds.", @@ -46,7 +46,7 @@ def move_service( # Check if we are on the same volume old_volume = service.get_location() if old_volume == volume.name: - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error=f"{service_name} is already on this volume.", @@ -54,7 +54,7 @@ def move_service( return # Check if there is enough space on the new volume if int(volume.fsavail) < service.get_storage_usage(): - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error="Not enough space on the new volume.", @@ -62,7 +62,7 @@ def move_service( return # Make sure the volume is mounted if volume.name != "sda1" and f"/volumes/{volume.name}" not in volume.mountpoints: - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error="Volume is not mounted.", @@ -71,14 +71,14 @@ def move_service( # Make sure current actual directory exists and if its user and group are correct for folder in folder_names: if not pathlib.Path(f"/volumes/{old_volume}/{folder.name}").exists(): - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error=f"{service_name} is not found.", ) return if not pathlib.Path(f"/volumes/{old_volume}/{folder.name}").is_dir(): - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error=f"{service_name} is not a directory.", @@ -88,7 +88,7 @@ def move_service( not pathlib.Path(f"/volumes/{old_volume}/{folder.name}").owner() == folder.owner ): - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error=f"{service_name} owner is not {folder.owner}.", @@ -96,7 +96,7 @@ def move_service( return # Stop service - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, status_text=f"Stopping {service_name}...", @@ -113,7 +113,7 @@ def move_service( break time.sleep(1) else: - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error=f"{service_name} did not stop in 30 seconds.", @@ -121,7 +121,7 @@ def move_service( return # Unmount old volume - Jobs.get_instance().update( + Jobs.update( job=job, status_text="Unmounting old folder...", status=JobStatus.RUNNING, @@ -134,14 +134,14 @@ def move_service( check=True, ) except subprocess.CalledProcessError: - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error="Unable to unmount old volume.", ) return # Move data to new volume and set correct permissions - Jobs.get_instance().update( + Jobs.update( job=job, status_text="Moving data to new volume...", status=JobStatus.RUNNING, @@ -154,14 +154,14 @@ def move_service( f"/volumes/{old_volume}/{folder.name}", f"/volumes/{volume.name}/{folder.name}", ) - Jobs.get_instance().update( + Jobs.update( job=job, status_text="Moving data to new volume...", status=JobStatus.RUNNING, progress=current_progress + folder_percentage, ) - Jobs.get_instance().update( + Jobs.update( job=job, status_text=f"Making sure {service_name} owns its files...", status=JobStatus.RUNNING, @@ -180,14 +180,14 @@ def move_service( ) except subprocess.CalledProcessError as error: print(error.output) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.RUNNING, error=f"Unable to set ownership of new volume. {service_name} may not be able to access its files. Continuing anyway.", ) # Mount new volume - Jobs.get_instance().update( + Jobs.update( job=job, status_text=f"Mounting {service_name} data...", status=JobStatus.RUNNING, @@ -207,7 +207,7 @@ def move_service( ) except subprocess.CalledProcessError as error: print(error.output) - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.ERROR, error="Unable to mount new volume.", @@ -215,7 +215,7 @@ def move_service( return # Update userdata - Jobs.get_instance().update( + Jobs.update( job=job, status_text="Finishing move...", status=JobStatus.RUNNING, @@ -227,7 +227,7 @@ def move_service( user_data[userdata_location]["location"] = volume.name # Start service service.start() - Jobs.get_instance().update( + Jobs.update( job=job, status=JobStatus.FINISHED, result=f"{service_name} moved successfully.", diff --git a/selfprivacy_api/services/gitea/__init__.py b/selfprivacy_api/services/gitea/__init__.py index c6389bd..aacda5f 100644 --- a/selfprivacy_api/services/gitea/__init__.py +++ b/selfprivacy_api/services/gitea/__init__.py @@ -141,7 +141,7 @@ class Gitea(Service): ] def move_to_volume(self, volume: BlockDevice) -> Job: - job = Jobs.get_instance().add( + job = Jobs.add( type_id="services.gitea.move", name="Move Gitea", description=f"Moving Gitea data to {volume.name}", diff --git a/selfprivacy_api/services/mailserver/__init__.py b/selfprivacy_api/services/mailserver/__init__.py index 1a72f33..78a2441 100644 --- a/selfprivacy_api/services/mailserver/__init__.py +++ b/selfprivacy_api/services/mailserver/__init__.py @@ -149,7 +149,7 @@ class MailServer(Service): ] def move_to_volume(self, volume: BlockDevice) -> Job: - job = Jobs.get_instance().add( + job = Jobs.add( type_id="services.mailserver.move", name="Move Mail Server", description=f"Moving mailserver data to {volume.name}", diff --git a/selfprivacy_api/services/nextcloud/__init__.py b/selfprivacy_api/services/nextcloud/__init__.py index 4057b49..ad74354 100644 --- a/selfprivacy_api/services/nextcloud/__init__.py +++ b/selfprivacy_api/services/nextcloud/__init__.py @@ -149,7 +149,7 @@ class Nextcloud(Service): ] def move_to_volume(self, volume: BlockDevice) -> Job: - job = Jobs.get_instance().add( + job = Jobs.add( type_id="services.nextcloud.move", name="Move Nextcloud", description=f"Moving Nextcloud to volume {volume.name}", diff --git a/selfprivacy_api/services/pleroma/__init__.py b/selfprivacy_api/services/pleroma/__init__.py index 97c11f5..4d2b85e 100644 --- a/selfprivacy_api/services/pleroma/__init__.py +++ b/selfprivacy_api/services/pleroma/__init__.py @@ -129,7 +129,7 @@ class Pleroma(Service): ] def move_to_volume(self, volume: BlockDevice) -> Job: - job = Jobs.get_instance().add( + job = Jobs.add( type_id="services.pleroma.move", name="Move Pleroma", description=f"Moving Pleroma to volume {volume.name}", diff --git a/selfprivacy_api/utils/__init__.py b/selfprivacy_api/utils/__init__.py index 83213d7..96bf9d8 100644 --- a/selfprivacy_api/utils/__init__.py +++ b/selfprivacy_api/utils/__init__.py @@ -164,13 +164,25 @@ def parse_date(date_str: str) -> datetime.datetime: raise ValueError("Invalid date string") -def get_dkim_key(domain): +def get_dkim_key(domain, parse=True): """Get DKIM key from /var/dkim/.selector.txt""" if os.path.exists("/var/dkim/" + domain + ".selector.txt"): cat_process = subprocess.Popen( ["cat", "/var/dkim/" + domain + ".selector.txt"], stdout=subprocess.PIPE ) dkim = cat_process.communicate()[0] + if parse: + # Extract key from file + dkim = dkim.split(b"(")[1] + dkim = dkim.split(b")")[0] + # Replace all quotes with nothing + dkim = dkim.replace(b'"', b"") + # Trim whitespace, remove newlines and tabs + dkim = dkim.strip() + dkim = dkim.replace(b"\n", b"") + dkim = dkim.replace(b"\t", b"") + # Remove all redundant spaces + dkim = b" ".join(dkim.split()) return str(dkim, "utf-8") return None diff --git a/selfprivacy_api/utils/auth.py b/selfprivacy_api/utils/auth.py deleted file mode 100644 index ecaf9af..0000000 --- a/selfprivacy_api/utils/auth.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -"""Token management utils""" -import secrets -from datetime import datetime, timedelta -import re -import typing - -from pydantic import BaseModel -from mnemonic import Mnemonic - -from . import ReadUserData, UserDataFiles, WriteUserData, parse_date - -""" -Token are stored in the tokens.json file. -File contains device tokens, recovery token and new device auth token. -File structure: -{ - "tokens": [ - { - "token": "device token", - "name": "device name", - "date": "date of creation", - } - ], - "recovery_token": { - "token": "recovery token", - "date": "date of creation", - "expiration": "date of expiration", - "uses_left": "number of uses left" - }, - "new_device": { - "token": "new device auth token", - "date": "date of creation", - "expiration": "date of expiration", - } -} -Recovery token may or may not have expiration date and uses_left. -There may be no recovery token at all. -Device tokens must be unique. -""" - - -def _get_tokens(): - """Get all tokens as list of tokens of every device""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - return [token["token"] for token in tokens["tokens"]] - - -def _get_token_names(): - """Get all token names""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - return [t["name"] for t in tokens["tokens"]] - - -def _validate_token_name(name): - """Token name must be an alphanumeric string and not empty. - Replace invalid characters with '_' - If token name exists, add a random number to the end of the name until it is unique. - """ - if not re.match("^[a-zA-Z0-9]*$", name): - name = re.sub("[^a-zA-Z0-9]", "_", name) - if name == "": - name = "Unknown device" - while name in _get_token_names(): - name += str(secrets.randbelow(10)) - return name - - -def is_token_valid(token): - """Check if token is valid""" - if token in _get_tokens(): - return True - return False - - -def is_token_name_exists(token_name): - """Check if token name exists""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - return token_name in [t["name"] for t in tokens["tokens"]] - - -def is_token_name_pair_valid(token_name, token): - """Check if token name and token pair exists""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - for t in tokens["tokens"]: - if t["name"] == token_name and t["token"] == token: - return True - return False - - -def get_token_name(token: str) -> typing.Optional[str]: - """Return the name of the token provided""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - for t in tokens["tokens"]: - if t["token"] == token: - return t["name"] - return None - - -class BasicTokenInfo(BaseModel): - """Token info""" - - name: str - date: datetime - - -def get_tokens_info(): - """Get all tokens info without tokens themselves""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - return [ - BasicTokenInfo( - name=t["name"], - date=parse_date(t["date"]), - ) - for t in tokens["tokens"] - ] - - -def _generate_token(): - """Generates new token and makes sure it is unique""" - token = secrets.token_urlsafe(32) - while token in _get_tokens(): - token = secrets.token_urlsafe(32) - return token - - -def create_token(name): - """Create new token""" - token = _generate_token() - name = _validate_token_name(name) - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["tokens"].append( - { - "token": token, - "name": name, - "date": str(datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")), - } - ) - return token - - -def delete_token(token_name): - """Delete token""" - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["tokens"] = [t for t in tokens["tokens"] if t["name"] != token_name] - - -def refresh_token(token: str) -> typing.Optional[str]: - """Change the token field of the existing token""" - new_token = _generate_token() - with WriteUserData(UserDataFiles.TOKENS) as tokens: - for t in tokens["tokens"]: - if t["token"] == token: - t["token"] = new_token - return new_token - return None - - -def is_recovery_token_exists(): - """Check if recovery token exists""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - return "recovery_token" in tokens - - -def is_recovery_token_valid(): - """Check if recovery token is valid""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - if "recovery_token" not in tokens: - return False - recovery_token = tokens["recovery_token"] - if "uses_left" in recovery_token and recovery_token["uses_left"] is not None: - if recovery_token["uses_left"] <= 0: - return False - if "expiration" not in recovery_token or recovery_token["expiration"] is None: - return True - return datetime.now() < parse_date(recovery_token["expiration"]) - - -def get_recovery_token_status(): - """Get recovery token date of creation, expiration and uses left""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - if "recovery_token" not in tokens: - return None - recovery_token = tokens["recovery_token"] - return { - "date": recovery_token["date"], - "expiration": recovery_token["expiration"] - if "expiration" in recovery_token - else None, - "uses_left": recovery_token["uses_left"] - if "uses_left" in recovery_token - else None, - } - - -def _get_recovery_token(): - """Get recovery token""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - if "recovery_token" not in tokens: - return None - return tokens["recovery_token"]["token"] - - -def generate_recovery_token( - expiration: typing.Optional[datetime], uses_left: typing.Optional[int] -) -> str: - """Generate a 24 bytes recovery token and return a mneomnic word list. - Write a string representation of the recovery token to the tokens.json file. - """ - # expires must be a date or None - # uses_left must be an integer or None - if expiration is not None: - if not isinstance(expiration, datetime): - raise TypeError("expires must be a datetime object") - if uses_left is not None: - if not isinstance(uses_left, int): - raise TypeError("uses_left must be an integer") - if uses_left <= 0: - raise ValueError("uses_left must be greater than 0") - - recovery_token = secrets.token_bytes(24) - recovery_token_str = recovery_token.hex() - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["recovery_token"] = { - "token": recovery_token_str, - "date": str(datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")), - "expiration": expiration.strftime("%Y-%m-%dT%H:%M:%S.%f") - if expiration is not None - else None, - "uses_left": uses_left if uses_left is not None else None, - } - return Mnemonic(language="english").to_mnemonic(recovery_token) - - -def use_mnemonic_recoverery_token(mnemonic_phrase, name): - """Use the recovery token by converting the mnemonic word list to a byte array. - If the recovery token if invalid itself, return None - If the binary representation of phrase not matches - the byte array of the recovery token, return None. - If the mnemonic phrase is valid then generate a device token and return it. - Substract 1 from uses_left if it exists. - mnemonic_phrase is a string representation of the mnemonic word list. - """ - if not is_recovery_token_valid(): - return None - recovery_token_str = _get_recovery_token() - if recovery_token_str is None: - return None - recovery_token = bytes.fromhex(recovery_token_str) - if not Mnemonic(language="english").check(mnemonic_phrase): - return None - phrase_bytes = Mnemonic(language="english").to_entropy(mnemonic_phrase) - if phrase_bytes != recovery_token: - return None - token = _generate_token() - name = _validate_token_name(name) - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["tokens"].append( - { - "token": token, - "name": name, - "date": str(datetime.now()), - } - ) - if "recovery_token" in tokens: - if ( - "uses_left" in tokens["recovery_token"] - and tokens["recovery_token"]["uses_left"] is not None - ): - tokens["recovery_token"]["uses_left"] -= 1 - return token - - -def get_new_device_auth_token() -> str: - """Generate a new device auth token which is valid for 10 minutes - and return a mnemonic phrase representation - Write token to the new_device of the tokens.json file. - """ - token = secrets.token_bytes(16) - token_str = token.hex() - with WriteUserData(UserDataFiles.TOKENS) as tokens: - tokens["new_device"] = { - "token": token_str, - "date": str(datetime.now()), - "expiration": str(datetime.now() + timedelta(minutes=10)), - } - return Mnemonic(language="english").to_mnemonic(token) - - -def _get_new_device_auth_token(): - """Get new device auth token. If it is expired, return None""" - with ReadUserData(UserDataFiles.TOKENS) as tokens: - if "new_device" not in tokens: - return None - new_device = tokens["new_device"] - if "expiration" not in new_device: - return None - expiration = parse_date(new_device["expiration"]) - if datetime.now() > expiration: - return None - return new_device["token"] - - -def delete_new_device_auth_token(): - """Delete new device auth token""" - with WriteUserData(UserDataFiles.TOKENS) as tokens: - if "new_device" in tokens: - del tokens["new_device"] - - -def use_new_device_auth_token(mnemonic_phrase, name): - """Use the new device auth token by converting the mnemonic string to a byte array. - If the mnemonic phrase is valid then generate a device token and return it. - New device auth token must be deleted. - """ - token_str = _get_new_device_auth_token() - if token_str is None: - return None - token = bytes.fromhex(token_str) - if not Mnemonic(language="english").check(mnemonic_phrase): - return None - phrase_bytes = Mnemonic(language="english").to_entropy(mnemonic_phrase) - if phrase_bytes != token: - return None - token = create_token(name) - with WriteUserData(UserDataFiles.TOKENS) as tokens: - if "new_device" in tokens: - del tokens["new_device"] - return token diff --git a/selfprivacy_api/utils/block_devices.py b/selfprivacy_api/utils/block_devices.py index 9d96d52..0de3d90 100644 --- a/selfprivacy_api/utils/block_devices.py +++ b/selfprivacy_api/utils/block_devices.py @@ -4,6 +4,7 @@ import json import typing from selfprivacy_api.utils import WriteUserData +from selfprivacy_api.utils.singleton_metaclass import SingletonMetaclass def get_block_device(device_name): @@ -147,16 +148,9 @@ class BlockDevice: return False -class BlockDevices: +class BlockDevices(metaclass=SingletonMetaclass): """Singleton holding all Block devices""" - _instance = None - - def __new__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = super().__new__(cls) - return cls._instance - def __init__(self): self.block_devices = [] self.update() diff --git a/selfprivacy_api/utils/redis_pool.py b/selfprivacy_api/utils/redis_pool.py new file mode 100644 index 0000000..2f2cf21 --- /dev/null +++ b/selfprivacy_api/utils/redis_pool.py @@ -0,0 +1,41 @@ +""" +Redis pool module for selfprivacy_api +""" +import redis +from selfprivacy_api.utils.singleton_metaclass import SingletonMetaclass +from os import environ + +REDIS_SOCKET = "/run/redis-sp-api/redis.sock" + + +class RedisPool(metaclass=SingletonMetaclass): + """ + Redis connection pool singleton. + """ + + def __init__(self): + if "USE_REDIS_PORT" in environ.keys(): + self._pool = redis.ConnectionPool( + host="127.0.0.1", + port=int(environ["USE_REDIS_PORT"]), + decode_responses=True, + ) + + else: + self._pool = redis.ConnectionPool.from_url( + f"unix://{REDIS_SOCKET}", + decode_responses=True, + ) + self._pubsub_connection = self.get_connection() + + def get_connection(self): + """ + Get a connection from the pool. + """ + return redis.Redis(connection_pool=self._pool) + + def get_pubsub(self): + """ + Get a pubsub connection from the pool. + """ + return self._pubsub_connection.pubsub() diff --git a/selfprivacy_api/utils/singleton_metaclass.py b/selfprivacy_api/utils/singleton_metaclass.py new file mode 100644 index 0000000..685cef6 --- /dev/null +++ b/selfprivacy_api/utils/singleton_metaclass.py @@ -0,0 +1,23 @@ +""" +Singleton is a creational design pattern, which ensures that only +one object of its kind exists and provides a single point of access +to it for any other code. +""" +from threading import Lock + + +class SingletonMetaclass(type): + """ + This is a thread-safe implementation of Singleton. + """ + + _instances = {} + _lock: Lock = Lock() + + def __call__(cls, *args, **kwargs): + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super(SingletonMetaclass, cls).__call__( + *args, **kwargs + ) + return cls._instances[cls] diff --git a/setup.py b/setup.py index eabc165..51606b6 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="selfprivacy_api", - version="2.0.0", + version="2.1.2", packages=find_packages(), scripts=[ "selfprivacy_api/app.py", diff --git a/shell.nix b/shell.nix index b620ee2..2a59534 100644 --- a/shell.nix +++ b/shell.nix @@ -19,7 +19,45 @@ let fastapi uvicorn redis - strawberry-graphql + (buildPythonPackage rec { + pname = "strawberry-graphql"; + version = "0.123.0"; + format = "pyproject"; + patches = [ + ./strawberry-graphql.patch + ]; + propagatedBuildInputs = [ + typing-extensions + python-multipart + python-dateutil + # flask + pydantic + pygments + poetry + # flask-cors + (buildPythonPackage rec { + pname = "graphql-core"; + version = "3.2.0"; + format = "setuptools"; + src = fetchPypi { + inherit pname version; + sha256 = "sha256-huKgvgCL/eGe94OI3opyWh2UKpGQykMcJKYIN5c4A84="; + }; + checkInputs = [ + pytest-asyncio + pytest-benchmark + pytestCheckHook + ]; + pythonImportsCheck = [ + "graphql" + ]; + }) + ]; + src = fetchPypi { + inherit pname version; + sha256 = "KsmZ5Xv8tUg6yBxieAEtvoKoRG60VS+iVGV0X6oCExo="; + }; + }) ]); in pkgs.mkShell { @@ -27,7 +65,6 @@ pkgs.mkShell { sp-python pkgs.black pkgs.redis - pkgs.restic ]; shellHook = '' PYTHONPATH=${sp-python}/${sp-python.sitePackages} diff --git a/tests/test_block_device_utils/no_devices.json b/tests/test_block_device_utils/no_devices.json index 97300ca..c395b21 100644 --- a/tests/test_block_device_utils/no_devices.json +++ b/tests/test_block_device_utils/no_devices.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -49,6 +41,19 @@ "sshKeys": [ "ssh-rsa KEY test@pc" ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + }, "volumes": [ ] } diff --git a/tests/test_block_device_utils/only_root.json b/tests/test_block_device_utils/only_root.json index 0f8ec0d..1026ed0 100644 --- a/tests/test_block_device_utils/only_root.json +++ b/tests/test_block_device_utils/only_root.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -55,5 +47,18 @@ "mountPoint": "/volumes/sda1", "filesystem": "ext4" } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } diff --git a/tests/test_block_device_utils/undefined.json b/tests/test_block_device_utils/undefined.json index eb660cc..f5edda8 100644 --- a/tests/test_block_device_utils/undefined.json +++ b/tests/test_block_device_utils/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } diff --git a/tests/test_graphql/test_api_devices.py b/tests/test_graphql/test_api_devices.py index d8dc974..07cf42a 100644 --- a/tests/test_graphql/test_api_devices.py +++ b/tests/test_graphql/test_api_devices.py @@ -2,8 +2,14 @@ # pylint: disable=unused-argument # pylint: disable=missing-function-docstring import datetime +import pytest from mnemonic import Mnemonic +from selfprivacy_api.repositories.tokens.json_tokens_repository import ( + JsonTokensRepository, +) +from selfprivacy_api.models.tokens.token import Token + from tests.common import generate_api_query, read_json, write_json TOKENS_FILE_CONTETS = { @@ -30,6 +36,11 @@ devices { """ +@pytest.fixture +def token_repo(): + return JsonTokensRepository() + + def test_graphql_tokens_info(authorized_client, tokens_file): response = authorized_client.post( "/graphql", @@ -170,7 +181,7 @@ def test_graphql_refresh_token_unauthorized(client, tokens_file): assert response.json()["data"] is None -def test_graphql_refresh_token(authorized_client, tokens_file): +def test_graphql_refresh_token(authorized_client, tokens_file, token_repo): response = authorized_client.post( "/graphql", json={"query": REFRESH_TOKEN_MUTATION}, @@ -180,11 +191,12 @@ def test_graphql_refresh_token(authorized_client, tokens_file): assert response.json()["data"]["refreshDeviceApiToken"]["success"] is True assert response.json()["data"]["refreshDeviceApiToken"]["message"] is not None assert response.json()["data"]["refreshDeviceApiToken"]["code"] == 200 - assert read_json(tokens_file)["tokens"][0] == { - "token": response.json()["data"]["refreshDeviceApiToken"]["token"], - "name": "test_token", - "date": "2022-01-14 08:31:10.789314", - } + token = token_repo.get_token_by_name("test_token") + assert token == Token( + token=response.json()["data"]["refreshDeviceApiToken"]["token"], + device_name="test_token", + created_at=datetime.datetime(2022, 1, 14, 8, 31, 10, 789314), + ) NEW_DEVICE_KEY_MUTATION = """ diff --git a/tests/test_graphql/test_repository/test_json_tokens_repository.py b/tests/test_graphql/test_repository/test_json_tokens_repository.py new file mode 100644 index 0000000..af8c844 --- /dev/null +++ b/tests/test_graphql/test_repository/test_json_tokens_repository.py @@ -0,0 +1,218 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=missing-function-docstring +""" +tests that restrict json token repository implementation +""" + +import pytest + + +from datetime import datetime + +from selfprivacy_api.models.tokens.token import Token +from selfprivacy_api.repositories.tokens.exceptions import ( + TokenNotFound, + RecoveryKeyNotFound, + NewDeviceKeyNotFound, +) +from selfprivacy_api.repositories.tokens.json_tokens_repository import ( + JsonTokensRepository, +) + +from tests.common import read_json +from test_tokens_repository import ( + mock_recovery_key_generate, + mock_generate_token, + mock_new_device_key_generate, + empty_keys, +) + +ORIGINAL_TOKEN_CONTENT = [ + { + "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + "name": "primary_token", + "date": "2022-07-15 17:41:31.675698", + }, + { + "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", + "name": "second_token", + "date": "2022-07-15 17:41:31.675698Z", + }, + { + "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", + "name": "third_token", + "date": "2022-07-15T17:41:31.675698Z", + }, + { + "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", + "name": "forth_token", + "date": "2022-07-15T17:41:31.675698", + }, +] + + +@pytest.fixture +def tokens(mocker, datadir): + mocker.patch("selfprivacy_api.utils.TOKENS_FILE", new=datadir / "tokens.json") + assert read_json(datadir / "tokens.json")["tokens"] == ORIGINAL_TOKEN_CONTENT + return datadir + + +@pytest.fixture +def null_keys(mocker, datadir): + mocker.patch("selfprivacy_api.utils.TOKENS_FILE", new=datadir / "null_keys.json") + assert read_json(datadir / "null_keys.json")["recovery_token"] is None + assert read_json(datadir / "null_keys.json")["new_device"] is None + return datadir + + +def test_delete_token(tokens): + repo = JsonTokensRepository() + input_token = Token( + token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + device_name="primary_token", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + ) + + repo.delete_token(input_token) + assert read_json(tokens / "tokens.json")["tokens"] == [ + { + "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", + "name": "second_token", + "date": "2022-07-15 17:41:31.675698Z", + }, + { + "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", + "name": "third_token", + "date": "2022-07-15T17:41:31.675698Z", + }, + { + "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", + "name": "forth_token", + "date": "2022-07-15T17:41:31.675698", + }, + ] + + +def test_delete_not_found_token(tokens): + repo = JsonTokensRepository() + input_token = Token( + token="imbadtoken", + device_name="primary_token", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + ) + with pytest.raises(TokenNotFound): + assert repo.delete_token(input_token) is None + + assert read_json(tokens / "tokens.json")["tokens"] == ORIGINAL_TOKEN_CONTENT + + +def test_create_recovery_key(tokens, mock_recovery_key_generate): + repo = JsonTokensRepository() + + assert repo.create_recovery_key(uses_left=1, expiration=None) is not None + assert read_json(tokens / "tokens.json")["recovery_token"] == { + "token": "889bf49c1d3199d71a2e704718772bd53a422020334db051", + "date": "2022-07-15T17:41:31.675698", + "expiration": None, + "uses_left": 1, + } + + +def test_use_mnemonic_recovery_key_when_null(null_keys): + repo = JsonTokensRepository() + + with pytest.raises(RecoveryKeyNotFound): + assert ( + repo.use_mnemonic_recovery_key( + mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", + device_name="primary_token", + ) + is None + ) + + +def test_use_mnemonic_recovery_key(tokens, mock_generate_token): + repo = JsonTokensRepository() + + assert repo.use_mnemonic_recovery_key( + mnemonic_phrase="uniform clarify napkin bid dress search input armor police cross salon because myself uphold slice bamboo hungry park", + device_name="newdevice", + ) == Token( + token="ur71mC4aiI6FIYAN--cTL-38rPHS5D6NuB1bgN_qKF4", + device_name="newdevice", + created_at=datetime(2022, 11, 14, 6, 6, 32, 777123), + ) + + assert read_json(tokens / "tokens.json")["tokens"] == [ + { + "date": "2022-07-15 17:41:31.675698", + "name": "primary_token", + "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + }, + { + "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", + "name": "second_token", + "date": "2022-07-15 17:41:31.675698Z", + }, + { + "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", + "name": "third_token", + "date": "2022-07-15T17:41:31.675698Z", + }, + { + "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", + "name": "forth_token", + "date": "2022-07-15T17:41:31.675698", + }, + { + "date": "2022-11-14T06:06:32.777123", + "name": "newdevice", + "token": "ur71mC4aiI6FIYAN--cTL-38rPHS5D6NuB1bgN_qKF4", + }, + ] + assert read_json(tokens / "tokens.json")["recovery_token"] == { + "date": "2022-11-11T11:48:54.228038", + "expiration": None, + "token": "ed653e4b8b042b841d285fa7a682fa09e925ddb2d8906f54", + "uses_left": 1, + } + + +def test_get_new_device_key(tokens, mock_new_device_key_generate): + repo = JsonTokensRepository() + + assert repo.get_new_device_key() is not None + assert read_json(tokens / "tokens.json")["new_device"] == { + "date": "2022-07-15T17:41:31.675698", + "expiration": "2022-07-15T17:41:31.675698", + "token": "43478d05b35e4781598acd76e33832bb", + } + + +def test_delete_new_device_key(tokens): + repo = JsonTokensRepository() + + assert repo.delete_new_device_key() is None + assert "new_device" not in read_json(tokens / "tokens.json") + + +def test_delete_new_device_key_when_empty(empty_keys): + repo = JsonTokensRepository() + + repo.delete_new_device_key() + assert "new_device" not in read_json(empty_keys / "empty_keys.json") + + +def test_use_mnemonic_new_device_key_when_null(null_keys): + repo = JsonTokensRepository() + + with pytest.raises(NewDeviceKeyNotFound): + assert ( + repo.use_mnemonic_new_device_key( + device_name="imnew", + mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", + ) + is None + ) diff --git a/tests/test_graphql/test_repository/test_json_tokens_repository/empty_keys.json b/tests/test_graphql/test_repository/test_json_tokens_repository/empty_keys.json new file mode 100644 index 0000000..2131ddf --- /dev/null +++ b/tests/test_graphql/test_repository/test_json_tokens_repository/empty_keys.json @@ -0,0 +1,9 @@ +{ + "tokens": [ + { + "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + "name": "primary_token", + "date": "2022-07-15 17:41:31.675698" + } + ] +} diff --git a/tests/test_graphql/test_repository/test_json_tokens_repository/null_keys.json b/tests/test_graphql/test_repository/test_json_tokens_repository/null_keys.json new file mode 100644 index 0000000..45e6f90 --- /dev/null +++ b/tests/test_graphql/test_repository/test_json_tokens_repository/null_keys.json @@ -0,0 +1,26 @@ +{ + "tokens": [ + { + "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + "name": "primary_token", + "date": "2022-07-15 17:41:31.675698" + }, + { + "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", + "name": "second_token", + "date": "2022-07-15 17:41:31.675698Z" + }, + { + "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", + "name": "third_token", + "date": "2022-07-15T17:41:31.675698Z" + }, + { + "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", + "name": "forth_token", + "date": "2022-07-15T17:41:31.675698" + } + ], + "recovery_token": null, + "new_device": null +} diff --git a/tests/test_graphql/test_repository/test_json_tokens_repository/tokens.json b/tests/test_graphql/test_repository/test_json_tokens_repository/tokens.json new file mode 100644 index 0000000..bb1805c --- /dev/null +++ b/tests/test_graphql/test_repository/test_json_tokens_repository/tokens.json @@ -0,0 +1,35 @@ +{ + "tokens": [ + { + "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", + "name": "primary_token", + "date": "2022-07-15 17:41:31.675698" + }, + { + "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", + "name": "second_token", + "date": "2022-07-15 17:41:31.675698Z" + }, + { + "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", + "name": "third_token", + "date": "2022-07-15T17:41:31.675698Z" + }, + { + "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", + "name": "forth_token", + "date": "2022-07-15T17:41:31.675698" + } + ], + "recovery_token": { + "token": "ed653e4b8b042b841d285fa7a682fa09e925ddb2d8906f54", + "date": "2022-11-11T11:48:54.228038", + "expiration": null, + "uses_left": 2 + }, + "new_device": { + "token": "2237238de23dc71ab558e317bdb8ff8e", + "date": "2022-10-26 20:50:47.973212", + "expiration": "2022-10-26 21:00:47.974153" + } +} diff --git a/tests/test_graphql/test_repository/test_tokens_repository.py b/tests/test_graphql/test_repository/test_tokens_repository.py index 878e242..020a868 100644 --- a/tests/test_graphql/test_repository/test_tokens_repository.py +++ b/tests/test_graphql/test_repository/test_tokens_repository.py @@ -2,7 +2,8 @@ # pylint: disable=unused-argument # pylint: disable=missing-function-docstring -from datetime import datetime, timezone +from datetime import datetime, timedelta +from mnemonic import Mnemonic import pytest @@ -18,38 +19,22 @@ from selfprivacy_api.repositories.tokens.exceptions import ( from selfprivacy_api.repositories.tokens.json_tokens_repository import ( JsonTokensRepository, ) +from selfprivacy_api.repositories.tokens.redis_tokens_repository import ( + RedisTokensRepository, +) from tests.common import read_json -ORIGINAL_TOKEN_CONTENT = [ - { - "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - "name": "primary_token", - "date": "2022-07-15 17:41:31.675698", - }, - { - "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", - "name": "second_token", - "date": "2022-07-15 17:41:31.675698Z", - }, - { - "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", - "name": "third_token", - "date": "2022-07-15T17:41:31.675698Z", - }, - { - "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", - "name": "forth_token", - "date": "2022-07-15T17:41:31.675698", - }, +ORIGINAL_DEVICE_NAMES = [ + "primary_token", + "second_token", + "third_token", + "forth_token", ] -@pytest.fixture -def tokens(mocker, datadir): - mocker.patch("selfprivacy_api.utils.TOKENS_FILE", new=datadir / "tokens.json") - assert read_json(datadir / "tokens.json")["tokens"] == ORIGINAL_TOKEN_CONTENT - return datadir +def mnemonic_from_hex(hexkey): + return Mnemonic(language="english").to_mnemonic(bytes.fromhex(hexkey)) @pytest.fixture @@ -65,23 +50,10 @@ def empty_keys(mocker, datadir): return datadir -@pytest.fixture -def null_keys(mocker, datadir): - mocker.patch("selfprivacy_api.utils.TOKENS_FILE", new=datadir / "null_keys.json") - assert read_json(datadir / "null_keys.json")["recovery_token"] is None - assert read_json(datadir / "null_keys.json")["new_device"] is None - return datadir - - -class RecoveryKeyMockReturnNotValid: - def is_valid() -> bool: - return False - - @pytest.fixture def mock_new_device_key_generate(mocker): mock = mocker.patch( - "selfprivacy_api.repositories.tokens.json_tokens_repository.NewDeviceKey.generate", + "selfprivacy_api.models.tokens.new_device_key.NewDeviceKey.generate", autospec=True, return_value=NewDeviceKey( key="43478d05b35e4781598acd76e33832bb", @@ -92,10 +64,25 @@ def mock_new_device_key_generate(mocker): return mock +# mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", +@pytest.fixture +def mock_new_device_key_generate_for_mnemonic(mocker): + mock = mocker.patch( + "selfprivacy_api.models.tokens.new_device_key.NewDeviceKey.generate", + autospec=True, + return_value=NewDeviceKey( + key="2237238de23dc71ab558e317bdb8ff8e", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + expires_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + ), + ) + return mock + + @pytest.fixture def mock_generate_token(mocker): mock = mocker.patch( - "selfprivacy_api.repositories.tokens.json_tokens_repository.Token.generate", + "selfprivacy_api.models.tokens.token.Token.generate", autospec=True, return_value=Token( token="ur71mC4aiI6FIYAN--cTL-38rPHS5D6NuB1bgN_qKF4", @@ -107,11 +94,16 @@ def mock_generate_token(mocker): @pytest.fixture -def mock_get_recovery_key_return_not_valid(mocker): +def mock_recovery_key_generate_invalid(mocker): mock = mocker.patch( - "selfprivacy_api.repositories.tokens.json_tokens_repository.JsonTokensRepository.get_recovery_key", + "selfprivacy_api.models.tokens.recovery_key.RecoveryKey.generate", autospec=True, - return_value=RecoveryKeyMockReturnNotValid, + return_value=RecoveryKey( + key="889bf49c1d3199d71a2e704718772bd53a422020334db051", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + expires_at=None, + uses_left=0, + ), ) return mock @@ -119,7 +111,7 @@ def mock_get_recovery_key_return_not_valid(mocker): @pytest.fixture def mock_token_generate(mocker): mock = mocker.patch( - "selfprivacy_api.repositories.tokens.json_tokens_repository.Token.generate", + "selfprivacy_api.models.tokens.token.Token.generate", autospec=True, return_value=Token( token="ZuLNKtnxDeq6w2dpOJhbB3iat_sJLPTPl_rN5uc5MvM", @@ -133,7 +125,7 @@ def mock_token_generate(mocker): @pytest.fixture def mock_recovery_key_generate(mocker): mock = mocker.patch( - "selfprivacy_api.repositories.tokens.json_tokens_repository.RecoveryKey.generate", + "selfprivacy_api.models.tokens.recovery_key.RecoveryKey.generate", autospec=True, return_value=RecoveryKey( key="889bf49c1d3199d71a2e704718772bd53a422020334db051", @@ -145,127 +137,158 @@ def mock_recovery_key_generate(mocker): return mock +@pytest.fixture +def empty_json_repo(empty_keys): + repo = JsonTokensRepository() + for token in repo.get_tokens(): + repo.delete_token(token) + assert repo.get_tokens() == [] + return repo + + +@pytest.fixture +def empty_redis_repo(): + repo = RedisTokensRepository() + repo.reset() + assert repo.get_tokens() == [] + return repo + + +@pytest.fixture(params=["json", "redis"]) +def empty_repo(request, empty_json_repo, empty_redis_repo): + if request.param == "json": + return empty_json_repo + if request.param == "redis": + return empty_redis_repo + # return empty_json_repo + else: + raise NotImplementedError + + +@pytest.fixture +def some_tokens_repo(empty_repo): + for name in ORIGINAL_DEVICE_NAMES: + empty_repo.create_token(name) + assert len(empty_repo.get_tokens()) == len(ORIGINAL_DEVICE_NAMES) + for name in ORIGINAL_DEVICE_NAMES: + assert empty_repo.get_token_by_name(name) is not None + assert empty_repo.get_new_device_key() is not None + return empty_repo + + ############### # Test tokens # ############### -def test_get_token_by_token_string(tokens): - repo = JsonTokensRepository() +def test_get_token_by_token_string(some_tokens_repo): + repo = some_tokens_repo + test_token = repo.get_tokens()[2] - assert repo.get_token_by_token_string( - token_string="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI" - ) == Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) + assert repo.get_token_by_token_string(token_string=test_token.token) == test_token -def test_get_token_by_non_existent_token_string(tokens): - repo = JsonTokensRepository() +def test_get_token_by_non_existent_token_string(some_tokens_repo): + repo = some_tokens_repo with pytest.raises(TokenNotFound): assert repo.get_token_by_token_string(token_string="iamBadtoken") is None -def test_get_token_by_name(tokens): - repo = JsonTokensRepository() +def test_get_token_by_name(some_tokens_repo): + repo = some_tokens_repo - assert repo.get_token_by_name(token_name="primary_token") is not None - assert repo.get_token_by_name(token_name="primary_token") == Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) + token = repo.get_token_by_name(token_name="primary_token") + assert token is not None + assert token.device_name == "primary_token" + assert token in repo.get_tokens() -def test_get_token_by_non_existent_name(tokens): - repo = JsonTokensRepository() +def test_get_token_by_non_existent_name(some_tokens_repo): + repo = some_tokens_repo with pytest.raises(TokenNotFound): assert repo.get_token_by_name(token_name="badname") is None -def test_get_tokens(tokens): - repo = JsonTokensRepository() - - assert repo.get_tokens() == [ - Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ), - Token( - token="3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", - device_name="second_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698, tzinfo=timezone.utc), - ), - Token( - token="LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", - device_name="third_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698, tzinfo=timezone.utc), - ), - Token( - token="dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", - device_name="forth_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ), - ] +def test_is_token_valid(some_tokens_repo): + repo = some_tokens_repo + token = repo.get_tokens()[0] + assert repo.is_token_valid(token.token) + assert not repo.is_token_valid("gibberish") -def test_get_tokens_when_one(empty_keys): - repo = JsonTokensRepository() - - assert repo.get_tokens() == [ - Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) - ] +def test_is_token_name_pair_valid(some_tokens_repo): + repo = some_tokens_repo + token = repo.get_tokens()[0] + assert repo.is_token_name_pair_valid(token.device_name, token.token) + assert not repo.is_token_name_pair_valid(token.device_name, "gibberish") + assert not repo.is_token_name_pair_valid("gibberish", token.token) -def test_create_token(tokens, mock_token_generate): - repo = JsonTokensRepository() +def test_is_token_name_exists(some_tokens_repo): + repo = some_tokens_repo + token = repo.get_tokens()[0] + assert repo.is_token_name_exists(token.device_name) + assert not repo.is_token_name_exists("gibberish") + + +def test_get_tokens(some_tokens_repo): + repo = some_tokens_repo + tokenstrings = [] + # we cannot insert tokens directly via api, so we check meta-properties instead + for token in repo.get_tokens(): + len(token.token) == 43 # assuming secrets.token_urlsafe + assert token.token not in tokenstrings + tokenstrings.append(token.token) + assert token.created_at.day == datetime.today().day + + +def test_create_token(empty_repo, mock_token_generate): + repo = empty_repo assert repo.create_token(device_name="IamNewDevice") == Token( token="ZuLNKtnxDeq6w2dpOJhbB3iat_sJLPTPl_rN5uc5MvM", device_name="IamNewDevice", created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), ) - - -def test_delete_token(tokens): - repo = JsonTokensRepository() - input_token = Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) - - repo.delete_token(input_token) - assert read_json(tokens / "tokens.json")["tokens"] == [ - { - "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", - "name": "second_token", - "date": "2022-07-15 17:41:31.675698Z", - }, - { - "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", - "name": "third_token", - "date": "2022-07-15T17:41:31.675698Z", - }, - { - "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", - "name": "forth_token", - "date": "2022-07-15T17:41:31.675698", - }, + assert repo.get_tokens() == [ + Token( + token="ZuLNKtnxDeq6w2dpOJhbB3iat_sJLPTPl_rN5uc5MvM", + device_name="IamNewDevice", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + ) ] -def test_delete_not_found_token(tokens): - repo = JsonTokensRepository() +def test_create_token_existing(some_tokens_repo): + repo = some_tokens_repo + old_token = repo.get_tokens()[0] + + new_token = repo.create_token(device_name=old_token.device_name) + assert new_token.device_name != old_token.device_name + + assert old_token in repo.get_tokens() + assert new_token in repo.get_tokens() + + +def test_delete_token(some_tokens_repo): + repo = some_tokens_repo + original_tokens = repo.get_tokens() + input_token = original_tokens[1] + + repo.delete_token(input_token) + + tokens_after_delete = repo.get_tokens() + for token in original_tokens: + if token != input_token: + assert token in tokens_after_delete + assert len(original_tokens) == len(tokens_after_delete) + 1 + + +def test_delete_not_found_token(some_tokens_repo): + repo = some_tokens_repo + initial_tokens = repo.get_tokens() input_token = Token( token="imbadtoken", device_name="primary_token", @@ -274,26 +297,27 @@ def test_delete_not_found_token(tokens): with pytest.raises(TokenNotFound): assert repo.delete_token(input_token) is None - assert read_json(tokens / "tokens.json")["tokens"] == ORIGINAL_TOKEN_CONTENT + new_tokens = repo.get_tokens() + assert len(new_tokens) == len(initial_tokens) + for token in initial_tokens: + assert token in new_tokens -def test_refresh_token(tokens, mock_token_generate): - repo = JsonTokensRepository() - input_token = Token( - token="KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - device_name="primary_token", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) +def test_refresh_token(some_tokens_repo): + repo = some_tokens_repo + input_token = some_tokens_repo.get_tokens()[0] - assert repo.refresh_token(input_token) == Token( - token="ZuLNKtnxDeq6w2dpOJhbB3iat_sJLPTPl_rN5uc5MvM", - device_name="IamNewDevice", - created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), - ) + output_token = repo.refresh_token(input_token) + + assert output_token.token != input_token.token + assert output_token.device_name == input_token.device_name + assert output_token.created_at == input_token.created_at + + assert output_token in repo.get_tokens() -def test_refresh_not_found_token(tokens, mock_token_generate): - repo = JsonTokensRepository() +def test_refresh_not_found_token(some_tokens_repo, mock_token_generate): + repo = some_tokens_repo input_token = Token( token="idontknowwhoiam", device_name="tellmewhoiam?", @@ -309,39 +333,26 @@ def test_refresh_not_found_token(tokens, mock_token_generate): ################ -def test_get_recovery_key(tokens): - repo = JsonTokensRepository() - - assert repo.get_recovery_key() == RecoveryKey( - key="ed653e4b8b042b841d285fa7a682fa09e925ddb2d8906f54", - created_at=datetime(2022, 11, 11, 11, 48, 54, 228038), - expires_at=None, - uses_left=2, - ) - - -def test_get_recovery_key_when_empty(empty_keys): - repo = JsonTokensRepository() +def test_get_recovery_key_when_empty(empty_repo): + repo = empty_repo assert repo.get_recovery_key() is None -def test_create_recovery_key(tokens, mock_recovery_key_generate): - repo = JsonTokensRepository() +def test_create_get_recovery_key(some_tokens_repo, mock_recovery_key_generate): + repo = some_tokens_repo assert repo.create_recovery_key(uses_left=1, expiration=None) is not None - assert read_json(tokens / "tokens.json")["recovery_token"] == { - "token": "889bf49c1d3199d71a2e704718772bd53a422020334db051", - "date": "2022-07-15T17:41:31.675698", - "expiration": None, - "uses_left": 1, - } + assert repo.get_recovery_key() == RecoveryKey( + key="889bf49c1d3199d71a2e704718772bd53a422020334db051", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + expires_at=None, + uses_left=1, + ) -def test_use_mnemonic_recovery_key_when_empty( - empty_keys, mock_recovery_key_generate, mock_token_generate -): - repo = JsonTokensRepository() +def test_use_mnemonic_recovery_key_when_empty(empty_repo): + repo = empty_repo with pytest.raises(RecoveryKeyNotFound): assert ( @@ -354,9 +365,10 @@ def test_use_mnemonic_recovery_key_when_empty( def test_use_mnemonic_not_valid_recovery_key( - tokens, mock_get_recovery_key_return_not_valid + some_tokens_repo, mock_recovery_key_generate_invalid ): - repo = JsonTokensRepository() + repo = some_tokens_repo + assert repo.create_recovery_key(uses_left=0, expiration=None) is not None with pytest.raises(RecoveryKeyNotFound): assert ( @@ -368,8 +380,26 @@ def test_use_mnemonic_not_valid_recovery_key( ) -def test_use_mnemonic_not_mnemonic_recovery_key(tokens): - repo = JsonTokensRepository() +def test_use_mnemonic_expired_recovery_key( + some_tokens_repo, +): + repo = some_tokens_repo + expiration = datetime.now() - timedelta(minutes=5) + assert repo.create_recovery_key(uses_left=2, expiration=expiration) is not None + recovery_key = repo.get_recovery_key() + assert recovery_key.expires_at == expiration + assert not repo.is_recovery_key_valid() + + with pytest.raises(RecoveryKeyNotFound): + token = repo.use_mnemonic_recovery_key( + mnemonic_phrase=mnemonic_from_hex(recovery_key.key), + device_name="newdevice", + ) + + +def test_use_mnemonic_not_mnemonic_recovery_key(some_tokens_repo): + repo = some_tokens_repo + assert repo.create_recovery_key(uses_left=1, expiration=None) is not None with pytest.raises(InvalidMnemonic): assert ( @@ -381,8 +411,9 @@ def test_use_mnemonic_not_mnemonic_recovery_key(tokens): ) -def test_use_not_mnemonic_recovery_key(tokens): - repo = JsonTokensRepository() +def test_use_not_mnemonic_recovery_key(some_tokens_repo): + repo = some_tokens_repo + assert repo.create_recovery_key(uses_left=1, expiration=None) is not None with pytest.raises(InvalidMnemonic): assert ( @@ -394,8 +425,9 @@ def test_use_not_mnemonic_recovery_key(tokens): ) -def test_use_not_found_mnemonic_recovery_key(tokens): - repo = JsonTokensRepository() +def test_use_not_found_mnemonic_recovery_key(some_tokens_repo): + repo = some_tokens_repo + assert repo.create_recovery_key(uses_left=1, expiration=None) is not None with pytest.raises(RecoveryKeyNotFound): assert ( @@ -407,78 +439,39 @@ def test_use_not_found_mnemonic_recovery_key(tokens): ) -def test_use_menemonic_recovery_key_when_empty(empty_keys): - repo = JsonTokensRepository() - - with pytest.raises(RecoveryKeyNotFound): - assert ( - repo.use_mnemonic_recovery_key( - mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", - device_name="primary_token", - ) - is None - ) +@pytest.fixture(params=["recovery_uses_1", "recovery_eternal"]) +def recovery_key_uses_left(request): + if request.param == "recovery_uses_1": + return 1 + if request.param == "recovery_eternal": + return None -def test_use_menemonic_recovery_key_when_null(null_keys): - repo = JsonTokensRepository() +def test_use_mnemonic_recovery_key(some_tokens_repo, recovery_key_uses_left): + repo = some_tokens_repo + assert ( + repo.create_recovery_key(uses_left=recovery_key_uses_left, expiration=None) + is not None + ) + assert repo.is_recovery_key_valid() + recovery_key = repo.get_recovery_key() - with pytest.raises(RecoveryKeyNotFound): - assert ( - repo.use_mnemonic_recovery_key( - mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", - device_name="primary_token", - ) - is None - ) - - -def test_use_mnemonic_recovery_key(tokens, mock_generate_token): - repo = JsonTokensRepository() - - assert repo.use_mnemonic_recovery_key( - mnemonic_phrase="uniform clarify napkin bid dress search input armor police cross salon because myself uphold slice bamboo hungry park", + token = repo.use_mnemonic_recovery_key( + mnemonic_phrase=mnemonic_from_hex(recovery_key.key), device_name="newdevice", - ) == Token( - token="ur71mC4aiI6FIYAN--cTL-38rPHS5D6NuB1bgN_qKF4", - device_name="newdevice", - created_at=datetime(2022, 11, 14, 6, 6, 32, 777123), ) - assert read_json(tokens / "tokens.json")["tokens"] == [ - { - "date": "2022-07-15 17:41:31.675698", - "name": "primary_token", - "token": "KG9ni-B-CMPk327Zv1qC7YBQaUGaBUcgdkvMvQ2atFI", - }, - { - "token": "3JKgLOtFu6ZHgE4OU-R-VdW47IKpg-YQL0c6n7bol68", - "name": "second_token", - "date": "2022-07-15 17:41:31.675698Z", - }, - { - "token": "LYiwFDekvALKTQSjk7vtMQuNP_6wqKuV-9AyMKytI_8", - "name": "third_token", - "date": "2022-07-15T17:41:31.675698Z", - }, - { - "token": "dD3CFPcEZvapscgzWb7JZTLog7OMkP7NzJeu2fAazXM", - "name": "forth_token", - "date": "2022-07-15T17:41:31.675698", - }, - { - "date": "2022-11-14T06:06:32.777123", - "name": "newdevice", - "token": "ur71mC4aiI6FIYAN--cTL-38rPHS5D6NuB1bgN_qKF4", - }, - ] - - assert read_json(tokens / "tokens.json")["recovery_token"] == { - "date": "2022-11-11T11:48:54.228038", - "expiration": None, - "token": "ed653e4b8b042b841d285fa7a682fa09e925ddb2d8906f54", - "uses_left": 1, - } + assert token.device_name == "newdevice" + assert token in repo.get_tokens() + new_uses = None + if recovery_key_uses_left is not None: + new_uses = recovery_key_uses_left - 1 + assert repo.get_recovery_key() == RecoveryKey( + key=recovery_key.key, + created_at=recovery_key.created_at, + expires_at=None, + uses_left=new_uses, + ) ################## @@ -486,35 +479,31 @@ def test_use_mnemonic_recovery_key(tokens, mock_generate_token): ################## -def test_get_new_device_key(tokens, mock_new_device_key_generate): - repo = JsonTokensRepository() +def test_get_new_device_key(some_tokens_repo, mock_new_device_key_generate): + repo = some_tokens_repo - assert repo.get_new_device_key() is not None - assert read_json(tokens / "tokens.json")["new_device"] == { - "date": "2022-07-15T17:41:31.675698", - "expiration": "2022-07-15T17:41:31.675698", - "token": "43478d05b35e4781598acd76e33832bb", - } + assert repo.get_new_device_key() == NewDeviceKey( + key="43478d05b35e4781598acd76e33832bb", + created_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + expires_at=datetime(2022, 7, 15, 17, 41, 31, 675698), + ) -def test_delete_new_device_key(tokens): - repo = JsonTokensRepository() +def test_delete_new_device_key(some_tokens_repo): + repo = some_tokens_repo assert repo.delete_new_device_key() is None - assert "new_device" not in read_json(tokens / "tokens.json") + # we cannot say if there is ot not without creating it? -def test_delete_new_device_key_when_empty(empty_keys): - repo = JsonTokensRepository() +def test_delete_new_device_key_when_empty(empty_repo): + repo = empty_repo - repo.delete_new_device_key() - assert "new_device" not in read_json(empty_keys / "empty_keys.json") + assert repo.delete_new_device_key() is None -def test_use_invalid_mnemonic_new_device_key( - tokens, mock_new_device_key_generate, datadir, mock_token_generate -): - repo = JsonTokensRepository() +def test_use_invalid_mnemonic_new_device_key(some_tokens_repo): + repo = some_tokens_repo with pytest.raises(InvalidMnemonic): assert ( @@ -527,9 +516,10 @@ def test_use_invalid_mnemonic_new_device_key( def test_use_not_exists_mnemonic_new_device_key( - tokens, mock_new_device_key_generate, mock_token_generate + empty_repo, mock_new_device_key_generate ): - repo = JsonTokensRepository() + repo = empty_repo + assert repo.get_new_device_key() is not None with pytest.raises(NewDeviceKeyNotFound): assert ( @@ -541,36 +531,54 @@ def test_use_not_exists_mnemonic_new_device_key( ) -def test_use_mnemonic_new_device_key( - tokens, mock_new_device_key_generate, mock_token_generate -): - repo = JsonTokensRepository() +def test_use_mnemonic_new_device_key(empty_repo): + repo = empty_repo + key = repo.get_new_device_key() + assert key is not None - assert ( - repo.use_mnemonic_new_device_key( - device_name="imnew", - mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", - ) - is not None + mnemonic_phrase = mnemonic_from_hex(key.key) + + new_token = repo.use_mnemonic_new_device_key( + device_name="imnew", + mnemonic_phrase=mnemonic_phrase, ) - # assert read_json(datadir / "tokens.json")["new_device"] == [] + + assert new_token.device_name == "imnew" + assert new_token in repo.get_tokens() + + # we must delete the key after use + with pytest.raises(NewDeviceKeyNotFound): + assert ( + repo.use_mnemonic_new_device_key( + device_name="imnew", + mnemonic_phrase=mnemonic_phrase, + ) + is None + ) -def test_use_mnemonic_new_device_key_when_empty(empty_keys): - repo = JsonTokensRepository() - - with pytest.raises(NewDeviceKeyNotFound): - assert ( - repo.use_mnemonic_new_device_key( - device_name="imnew", - mnemonic_phrase="captain ribbon toddler settle symbol minute step broccoli bless universe divide bulb", - ) - is None - ) - - -def test_use_mnemonic_new_device_key_when_null(null_keys): - repo = JsonTokensRepository() +def test_use_mnemonic_expired_new_device_key( + some_tokens_repo, +): + repo = some_tokens_repo + expiration = datetime.now() - timedelta(minutes=5) + + key = repo.get_new_device_key() + assert key is not None + assert key.expires_at is not None + key.expires_at = expiration + assert not key.is_valid() + repo._store_new_device_key(key) + + with pytest.raises(NewDeviceKeyNotFound): + token = repo.use_mnemonic_new_device_key( + mnemonic_phrase=mnemonic_from_hex(key.key), + device_name="imnew", + ) + + +def test_use_mnemonic_new_device_key_when_empty(empty_repo): + repo = empty_repo with pytest.raises(NewDeviceKeyNotFound): assert ( diff --git a/tests/test_graphql/test_ssh/some_users.json b/tests/test_graphql/test_ssh/some_users.json index 569253a..c02d216 100644 --- a/tests/test_graphql/test_ssh/some_users.json +++ b/tests/test_graphql/test_ssh/some_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -67,5 +59,18 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_system/no_values.json b/tests/test_graphql/test_system/no_values.json index 59e5e71..779691f 100644 --- a/tests/test_graphql/test_system/no_values.json +++ b/tests/test_graphql/test_system/no_values.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -46,5 +38,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_system/turned_off.json b/tests/test_graphql/test_system/turned_off.json index f451683..5fc287c 100644 --- a/tests/test_graphql/test_system/turned_off.json +++ b/tests/test_graphql/test_system/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_system/turned_on.json b/tests/test_graphql/test_system/turned_on.json index 821875b..c6b758b 100644 --- a/tests/test_graphql/test_system/turned_on.json +++ b/tests/test_graphql/test_system/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -51,5 +43,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } diff --git a/tests/test_graphql/test_system/undefined.json b/tests/test_graphql/test_system/undefined.json index b67b296..2e31fea 100644 --- a/tests/test_graphql/test_system/undefined.json +++ b/tests/test_graphql/test_system/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -43,5 +35,18 @@ }, "sshKeys": [ "ssh-rsa KEY test@pc" - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_users/no_users.json b/tests/test_graphql/test_users/no_users.json index e5efe86..a40fb88 100644 --- a/tests/test_graphql/test_users/no_users.json +++ b/tests/test_graphql/test_users/no_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -50,5 +42,18 @@ "ssh-rsa KEY test@pc" ], "users": [ - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_users/one_user.json b/tests/test_graphql/test_users/one_user.json index 5df2108..7e1cced 100644 --- a/tests/test_graphql/test_users/one_user.json +++ b/tests/test_graphql/test_users/one_user.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -57,5 +49,18 @@ "ssh-rsa KEY user1@pc" ] } - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_users/some_users.json b/tests/test_graphql/test_users/some_users.json index 569253a..c02d216 100644 --- a/tests/test_graphql/test_users/some_users.json +++ b/tests/test_graphql/test_users/some_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -67,5 +59,18 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_graphql/test_users/undefined.json b/tests/test_graphql/test_users/undefined.json index 7b2cf8b..ae9cd9e 100644 --- a/tests/test_graphql/test_users/undefined.json +++ b/tests/test_graphql/test_users/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } +} diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 87f1386..56e4aa3 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,16 +1,96 @@ # pylint: disable=redefined-outer-name # pylint: disable=unused-argument -import json import pytest -from selfprivacy_api.utils import WriteUserData, ReadUserData from selfprivacy_api.jobs import Jobs, JobStatus +import selfprivacy_api.jobs as jobsmodule -def test_jobs(authorized_client, jobs_file, shared_datadir): - jobs = Jobs() +def test_add_reset(jobs_with_one_job): + jobs_with_one_job.reset() + assert jobs_with_one_job.get_jobs() == [] + + +def test_minimal_update(jobs_with_one_job): + jobs = jobs_with_one_job + test_job = jobs_with_one_job.get_jobs()[0] + + jobs.update(job=test_job, status=JobStatus.ERROR) + + assert jobs.get_jobs() == [test_job] + + +def test_remove_by_uid(jobs_with_one_job): + test_job = jobs_with_one_job.get_jobs()[0] + uid_str = str(test_job.uid) + + assert jobs_with_one_job.remove_by_uid(uid_str) + assert jobs_with_one_job.get_jobs() == [] + assert not jobs_with_one_job.remove_by_uid(uid_str) + + +def test_remove_update_nonexistent(jobs_with_one_job): + test_job = jobs_with_one_job.get_jobs()[0] + + jobs_with_one_job.remove(test_job) + assert jobs_with_one_job.get_jobs() == [] + + result = jobs_with_one_job.update(job=test_job, status=JobStatus.ERROR) + assert result == test_job # even though we might consider changing this behavior + + +def test_remove_get_nonexistent(jobs_with_one_job): + test_job = jobs_with_one_job.get_jobs()[0] + uid_str = str(test_job.uid) + assert jobs_with_one_job.get_job(uid_str) == test_job + + jobs_with_one_job.remove(test_job) + + assert jobs_with_one_job.get_job(uid_str) is None + + +def test_jobs(jobs_with_one_job): + jobs = jobs_with_one_job + test_job = jobs_with_one_job.get_jobs()[0] + assert not jobs.is_busy() + + jobs.update( + job=test_job, + name="Write Tests", + description="An oddly satisfying experience", + status=JobStatus.RUNNING, + status_text="Status text", + progress=50, + ) + + assert jobs.get_jobs() == [test_job] + assert jobs.is_busy() + + backup = jobsmodule.JOB_EXPIRATION_SECONDS + jobsmodule.JOB_EXPIRATION_SECONDS = 0 + + jobs.update( + job=test_job, + status=JobStatus.FINISHED, + status_text="Yaaay!", + progress=100, + ) + assert jobs.get_jobs() == [] + jobsmodule.JOB_EXPIRATION_SECONDS = backup + +@pytest.fixture +def jobs(): + j = Jobs() + j.reset() + assert j.get_jobs() == [] + yield j + j.reset() + + +@pytest.fixture +def jobs_with_one_job(jobs): test_job = jobs.add( type_id="test", name="Test job", @@ -19,32 +99,5 @@ def test_jobs(authorized_client, jobs_file, shared_datadir): status_text="Status text", progress=0, ) - assert jobs.get_jobs() == [test_job] - - jobs.update( - job=test_job, - status=JobStatus.RUNNING, - status_text="Status text", - progress=50, - ) - - assert jobs.get_jobs() == [test_job] - - -@pytest.fixture -def mock_subprocess_run(mocker): - mock = mocker.patch("subprocess.run", autospec=True) - return mock - - -@pytest.fixture -def mock_shutil_move(mocker): - mock = mocker.patch("shutil.move", autospec=True) - return mock - - -@pytest.fixture -def mock_shutil_chown(mocker): - mock = mocker.patch("shutil.chown", autospec=True) - return mock + return jobs diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2263e82 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,18 @@ +import pytest +from datetime import datetime, timedelta + +from selfprivacy_api.models.tokens.recovery_key import RecoveryKey +from selfprivacy_api.models.tokens.new_device_key import NewDeviceKey + + +def test_recovery_key_expired(): + expiration = datetime.now() - timedelta(minutes=5) + key = RecoveryKey.generate(expiration=expiration, uses_left=2) + assert not key.is_valid() + + +def test_new_device_key_expired(): + expiration = datetime.now() - timedelta(minutes=5) + key = NewDeviceKey.generate() + key.expires_at = expiration + assert not key.is_valid() diff --git a/tests/test_rest_endpoints/services/test_bitwarden/enable_undefined.json b/tests/test_rest_endpoints/services/test_bitwarden/enable_undefined.json index 05e04c1..1a95e85 100644 --- a/tests/test_rest_endpoints/services/test_bitwarden/enable_undefined.json +++ b/tests/test_rest_endpoints/services/test_bitwarden/enable_undefined.json @@ -1,18 +1,10 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false }, "bitwarden": { }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -47,5 +39,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_bitwarden/turned_off.json b/tests/test_rest_endpoints/services/test_bitwarden/turned_off.json index 7b2cf8b..c1691ea 100644 --- a/tests/test_rest_endpoints/services/test_bitwarden/turned_off.json +++ b/tests/test_rest_endpoints/services/test_bitwarden/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_bitwarden/turned_on.json b/tests/test_rest_endpoints/services/test_bitwarden/turned_on.json index 337e47f..42999d8 100644 --- a/tests/test_rest_endpoints/services/test_bitwarden/turned_on.json +++ b/tests/test_rest_endpoints/services/test_bitwarden/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_bitwarden/undefined.json b/tests/test_rest_endpoints/services/test_bitwarden/undefined.json index 625422b..ee288c2 100644 --- a/tests/test_rest_endpoints/services/test_bitwarden/undefined.json +++ b/tests/test_rest_endpoints/services/test_bitwarden/undefined.json @@ -1,16 +1,8 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -45,5 +37,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_gitea/enable_undefined.json b/tests/test_rest_endpoints/services/test_gitea/enable_undefined.json index 07b0e78..f9fb878 100644 --- a/tests/test_rest_endpoints/services/test_gitea/enable_undefined.json +++ b/tests/test_rest_endpoints/services/test_gitea/enable_undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -47,5 +39,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_gitea/turned_off.json b/tests/test_rest_endpoints/services/test_gitea/turned_off.json index 7b2cf8b..c1691ea 100644 --- a/tests/test_rest_endpoints/services/test_gitea/turned_off.json +++ b/tests/test_rest_endpoints/services/test_gitea/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_gitea/turned_on.json b/tests/test_rest_endpoints/services/test_gitea/turned_on.json index acb98ce..f9a1eaf 100644 --- a/tests/test_rest_endpoints/services/test_gitea/turned_on.json +++ b/tests/test_rest_endpoints/services/test_gitea/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_gitea/undefined.json b/tests/test_rest_endpoints/services/test_gitea/undefined.json index f689b2e..a50a070 100644 --- a/tests/test_rest_endpoints/services/test_gitea/undefined.json +++ b/tests/test_rest_endpoints/services/test_gitea/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -45,5 +37,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_mailserver.py b/tests/test_rest_endpoints/services/test_mailserver.py index 36cf615..2803683 100644 --- a/tests/test_rest_endpoints/services/test_mailserver.py +++ b/tests/test_rest_endpoints/services/test_mailserver.py @@ -2,6 +2,8 @@ import base64 import json import pytest +from selfprivacy_api.utils import get_dkim_key + ############################################################################### @@ -13,7 +15,10 @@ class ProcessMock: self.kwargs = kwargs def communicate(): - return (b"I am a DKIM key", None) + return ( + b'selector._domainkey\tIN\tTXT\t( "v=DKIM1; k=rsa; "\n\t "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNn/IhEz1SxgHxxxI8vlPYC2dNueiLe1GC4SYz8uHimC8SDkMvAwm7rqi2SimbFgGB5nccCNOqCkrIqJTCB9vufqBnVKAjshHqpOr5hk4JJ1T/AGQKWinstmDbfTLPYTbU8ijZrwwGeqQLlnXR5nSN0GB9GazheA9zaPsT6PV+aQIDAQAB" ) ; ----- DKIM key selector for example.com\n', + None, + ) class NoFileMock(ProcessMock): @@ -63,11 +68,27 @@ def test_illegal_methods(authorized_client, mock_subproccess_popen): assert response.status_code == 405 -def test_dkim_key(authorized_client, mock_subproccess_popen): +def test_get_dkim_key(mock_subproccess_popen): """Test DKIM key""" + dkim_key = get_dkim_key("example.com") + assert ( + dkim_key + == "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNn/IhEz1SxgHxxxI8vlPYC2dNueiLe1GC4SYz8uHimC8SDkMvAwm7rqi2SimbFgGB5nccCNOqCkrIqJTCB9vufqBnVKAjshHqpOr5hk4JJ1T/AGQKWinstmDbfTLPYTbU8ijZrwwGeqQLlnXR5nSN0GB9GazheA9zaPsT6PV+aQIDAQAB" + ) + assert mock_subproccess_popen.call_args[0][0] == [ + "cat", + "/var/dkim/example.com.selector.txt", + ] + + +def test_dkim_key(authorized_client, mock_subproccess_popen): + """Test old REST DKIM key endpoint""" response = authorized_client.get("/services/mailserver/dkim") assert response.status_code == 200 - assert base64.b64decode(response.text) == b"I am a DKIM key" + assert ( + base64.b64decode(response.text) + == b'selector._domainkey\tIN\tTXT\t( "v=DKIM1; k=rsa; "\n\t "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNn/IhEz1SxgHxxxI8vlPYC2dNueiLe1GC4SYz8uHimC8SDkMvAwm7rqi2SimbFgGB5nccCNOqCkrIqJTCB9vufqBnVKAjshHqpOr5hk4JJ1T/AGQKWinstmDbfTLPYTbU8ijZrwwGeqQLlnXR5nSN0GB9GazheA9zaPsT6PV+aQIDAQAB" ) ; ----- DKIM key selector for example.com\n' + ) assert mock_subproccess_popen.call_args[0][0] == [ "cat", "/var/dkim/example.com.selector.txt", diff --git a/tests/test_rest_endpoints/services/test_nextcloud/enable_undefined.json b/tests/test_rest_endpoints/services/test_nextcloud/enable_undefined.json index 68127f0..19f1f2d 100644 --- a/tests/test_rest_endpoints/services/test_nextcloud/enable_undefined.json +++ b/tests/test_rest_endpoints/services/test_nextcloud/enable_undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -47,5 +39,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_nextcloud/turned_off.json b/tests/test_rest_endpoints/services/test_nextcloud/turned_off.json index 375e70f..b80ad9e 100644 --- a/tests/test_rest_endpoints/services/test_nextcloud/turned_off.json +++ b/tests/test_rest_endpoints/services/test_nextcloud/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_nextcloud/turned_on.json b/tests/test_rest_endpoints/services/test_nextcloud/turned_on.json index 7b2cf8b..c1691ea 100644 --- a/tests/test_rest_endpoints/services/test_nextcloud/turned_on.json +++ b/tests/test_rest_endpoints/services/test_nextcloud/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_nextcloud/undefined.json b/tests/test_rest_endpoints/services/test_nextcloud/undefined.json index fb02c69..46c09f3 100644 --- a/tests/test_rest_endpoints/services/test_nextcloud/undefined.json +++ b/tests/test_rest_endpoints/services/test_nextcloud/undefined.json @@ -1,16 +1,8 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -40,5 +32,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ocserv/enable_undefined.json b/tests/test_rest_endpoints/services/test_ocserv/enable_undefined.json index 88d804d..e080110 100644 --- a/tests/test_rest_endpoints/services/test_ocserv/enable_undefined.json +++ b/tests/test_rest_endpoints/services/test_ocserv/enable_undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -47,5 +39,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ocserv/turned_off.json b/tests/test_rest_endpoints/services/test_ocserv/turned_off.json index 6220561..1c08123 100644 --- a/tests/test_rest_endpoints/services/test_ocserv/turned_off.json +++ b/tests/test_rest_endpoints/services/test_ocserv/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ocserv/turned_on.json b/tests/test_rest_endpoints/services/test_ocserv/turned_on.json index 375e70f..b80ad9e 100644 --- a/tests/test_rest_endpoints/services/test_ocserv/turned_on.json +++ b/tests/test_rest_endpoints/services/test_ocserv/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ocserv/undefined.json b/tests/test_rest_endpoints/services/test_ocserv/undefined.json index f7e21bf..12eb73a 100644 --- a/tests/test_rest_endpoints/services/test_ocserv/undefined.json +++ b/tests/test_rest_endpoints/services/test_ocserv/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -45,5 +37,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_pleroma/enable_undefined.json b/tests/test_rest_endpoints/services/test_pleroma/enable_undefined.json index 20ab960..0903875 100644 --- a/tests/test_rest_endpoints/services/test_pleroma/enable_undefined.json +++ b/tests/test_rest_endpoints/services/test_pleroma/enable_undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -47,5 +39,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_pleroma/turned_off.json b/tests/test_rest_endpoints/services/test_pleroma/turned_off.json index b6d5fd6..813c01f 100644 --- a/tests/test_rest_endpoints/services/test_pleroma/turned_off.json +++ b/tests/test_rest_endpoints/services/test_pleroma/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_pleroma/turned_on.json b/tests/test_rest_endpoints/services/test_pleroma/turned_on.json index 6220561..1c08123 100644 --- a/tests/test_rest_endpoints/services/test_pleroma/turned_on.json +++ b/tests/test_rest_endpoints/services/test_pleroma/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_pleroma/undefined.json b/tests/test_rest_endpoints/services/test_pleroma/undefined.json index b909a95..77d8ad2 100644 --- a/tests/test_rest_endpoints/services/test_pleroma/undefined.json +++ b/tests/test_rest_endpoints/services/test_pleroma/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -45,5 +37,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_restic.py b/tests/test_rest_endpoints/services/test_restic.py index 9502be5..844ff34 100644 --- a/tests/test_rest_endpoints/services/test_restic.py +++ b/tests/test_rest_endpoints/services/test_restic.py @@ -161,7 +161,7 @@ def mock_restic_tasks(mocker): @pytest.fixture def undefined_settings(mocker, datadir): mocker.patch("selfprivacy_api.utils.USERDATA_FILE", new=datadir / "undefined.json") - assert "backblaze" not in read_json(datadir / "undefined.json") + assert "backup" not in read_json(datadir / "undefined.json") return datadir @@ -170,20 +170,22 @@ def some_settings(mocker, datadir): mocker.patch( "selfprivacy_api.utils.USERDATA_FILE", new=datadir / "some_values.json" ) - assert "backblaze" in read_json(datadir / "some_values.json") - assert read_json(datadir / "some_values.json")["backblaze"]["accountId"] == "ID" - assert read_json(datadir / "some_values.json")["backblaze"]["accountKey"] == "KEY" - assert read_json(datadir / "some_values.json")["backblaze"]["bucket"] == "BUCKET" + assert "backup" in read_json(datadir / "some_values.json") + assert read_json(datadir / "some_values.json")["backup"]["provider"] == "BACKBLAZE" + assert read_json(datadir / "some_values.json")["backup"]["accountId"] == "ID" + assert read_json(datadir / "some_values.json")["backup"]["accountKey"] == "KEY" + assert read_json(datadir / "some_values.json")["backup"]["bucket"] == "BUCKET" return datadir @pytest.fixture def no_values(mocker, datadir): mocker.patch("selfprivacy_api.utils.USERDATA_FILE", new=datadir / "no_values.json") - assert "backblaze" in read_json(datadir / "no_values.json") - assert "accountId" not in read_json(datadir / "no_values.json")["backblaze"] - assert "accountKey" not in read_json(datadir / "no_values.json")["backblaze"] - assert "bucket" not in read_json(datadir / "no_values.json")["backblaze"] + assert "backup" in read_json(datadir / "no_values.json") + assert "provider" not in read_json(datadir / "no_values.json")["backup"] + assert "accountId" not in read_json(datadir / "no_values.json")["backup"] + assert "accountKey" not in read_json(datadir / "no_values.json")["backup"] + assert "bucket" not in read_json(datadir / "no_values.json")["backup"] return datadir @@ -462,7 +464,8 @@ def test_set_backblaze_config( ) assert response.status_code == 200 assert mock_restic_tasks.update_keys_from_userdata.call_count == 1 - assert read_json(some_settings / "some_values.json")["backblaze"] == { + assert read_json(some_settings / "some_values.json")["backup"] == { + "provider": "BACKBLAZE", "accountId": "123", "accountKey": "456", "bucket": "789", @@ -478,7 +481,8 @@ def test_set_backblaze_config_on_undefined( ) assert response.status_code == 200 assert mock_restic_tasks.update_keys_from_userdata.call_count == 1 - assert read_json(undefined_settings / "undefined.json")["backblaze"] == { + assert read_json(undefined_settings / "undefined.json")["backup"] == { + "provider": "BACKBLAZE", "accountId": "123", "accountKey": "456", "bucket": "789", @@ -494,7 +498,8 @@ def test_set_backblaze_config_on_no_values( ) assert response.status_code == 200 assert mock_restic_tasks.update_keys_from_userdata.call_count == 1 - assert read_json(no_values / "no_values.json")["backblaze"] == { + assert read_json(no_values / "no_values.json")["backup"] == { + "provider": "BACKBLAZE", "accountId": "123", "accountKey": "456", "bucket": "789", diff --git a/tests/test_rest_endpoints/services/test_restic/no_values.json b/tests/test_rest_endpoints/services/test_restic/no_values.json index c1ef7a0..3b4a2f5 100644 --- a/tests/test_rest_endpoints/services/test_restic/no_values.json +++ b/tests/test_rest_endpoints/services/test_restic/no_values.json @@ -1,6 +1,4 @@ { - "backblaze": { - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -8,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -64,5 +59,14 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_restic/some_values.json b/tests/test_rest_endpoints/services/test_restic/some_values.json index a7dbf39..c003d10 100644 --- a/tests/test_rest_endpoints/services/test_restic/some_values.json +++ b/tests/test_rest_endpoints/services/test_restic/some_values.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "BUCKET" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -67,5 +59,18 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] -} \ No newline at end of file + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "BUCKET" + } +} diff --git a/tests/test_rest_endpoints/services/test_restic/undefined.json b/tests/test_rest_endpoints/services/test_restic/undefined.json index 59e42a0..5bd1220 100644 --- a/tests/test_rest_endpoints/services/test_restic/undefined.json +++ b/tests/test_rest_endpoints/services/test_restic/undefined.json @@ -6,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -62,5 +59,12 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/all_off.json b/tests/test_rest_endpoints/services/test_ssh/all_off.json index e1b8510..051d364 100644 --- a/tests/test_rest_endpoints/services/test_ssh/all_off.json +++ b/tests/test_rest_endpoints/services/test_ssh/all_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/root_and_admin_have_keys.json b/tests/test_rest_endpoints/services/test_ssh/root_and_admin_have_keys.json index 7b2cf8b..c1691ea 100644 --- a/tests/test_rest_endpoints/services/test_ssh/root_and_admin_have_keys.json +++ b/tests/test_rest_endpoints/services/test_ssh/root_and_admin_have_keys.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/some_users.json b/tests/test_rest_endpoints/services/test_ssh/some_users.json index 569253a..df6380a 100644 --- a/tests/test_rest_endpoints/services/test_ssh/some_users.json +++ b/tests/test_rest_endpoints/services/test_ssh/some_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -67,5 +59,18 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/turned_off.json b/tests/test_rest_endpoints/services/test_ssh/turned_off.json index b09395b..3856c80 100644 --- a/tests/test_rest_endpoints/services/test_ssh/turned_off.json +++ b/tests/test_rest_endpoints/services/test_ssh/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -42,5 +34,18 @@ "enable": true, "allowReboot": true }, - "timezone": "Europe/Moscow" + "timezone": "Europe/Moscow", + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/turned_on.json b/tests/test_rest_endpoints/services/test_ssh/turned_on.json index 44b28ce..e60c57f 100644 --- a/tests/test_rest_endpoints/services/test_ssh/turned_on.json +++ b/tests/test_rest_endpoints/services/test_ssh/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -42,5 +34,18 @@ "enable": true, "allowReboot": true }, - "timezone": "Europe/Moscow" + "timezone": "Europe/Moscow", + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/undefined.json b/tests/test_rest_endpoints/services/test_ssh/undefined.json index a214cc3..7c9af37 100644 --- a/tests/test_rest_endpoints/services/test_ssh/undefined.json +++ b/tests/test_rest_endpoints/services/test_ssh/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -38,5 +30,18 @@ "enable": true, "allowReboot": true }, - "timezone": "Europe/Moscow" + "timezone": "Europe/Moscow", + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/services/test_ssh/undefined_values.json b/tests/test_rest_endpoints/services/test_ssh/undefined_values.json index 235a220..b7b03d3 100644 --- a/tests/test_rest_endpoints/services/test_ssh/undefined_values.json +++ b/tests/test_rest_endpoints/services/test_ssh/undefined_values.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -42,5 +34,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_auth.py b/tests/test_rest_endpoints/test_auth.py index 1083be5..12de0cf 100644 --- a/tests/test_rest_endpoints/test_auth.py +++ b/tests/test_rest_endpoints/test_auth.py @@ -5,6 +5,12 @@ import datetime import pytest from mnemonic import Mnemonic +from selfprivacy_api.repositories.tokens.json_tokens_repository import ( + JsonTokensRepository, +) + +TOKEN_REPO = JsonTokensRepository() + from tests.common import read_json, write_json @@ -97,7 +103,7 @@ def test_refresh_token(authorized_client, tokens_file): response = authorized_client.post("/auth/tokens") assert response.status_code == 200 new_token = response.json()["token"] - assert read_json(tokens_file)["tokens"][0]["token"] == new_token + assert TOKEN_REPO.get_token_by_token_string(new_token) is not None # new device diff --git a/tests/test_rest_endpoints/test_system/no_values.json b/tests/test_rest_endpoints/test_system/no_values.json index 59e5e71..5c1431e 100644 --- a/tests/test_rest_endpoints/test_system/no_values.json +++ b/tests/test_rest_endpoints/test_system/no_values.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -46,5 +38,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_system/turned_off.json b/tests/test_rest_endpoints/test_system/turned_off.json index f451683..2336f36 100644 --- a/tests/test_rest_endpoints/test_system/turned_off.json +++ b/tests/test_rest_endpoints/test_system/turned_off.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_system/turned_on.json b/tests/test_rest_endpoints/test_system/turned_on.json index 337e47f..42999d8 100644 --- a/tests/test_rest_endpoints/test_system/turned_on.json +++ b/tests/test_rest_endpoints/test_system/turned_on.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_system/undefined.json b/tests/test_rest_endpoints/test_system/undefined.json index b67b296..6b9f3fd 100644 --- a/tests/test_rest_endpoints/test_system/undefined.json +++ b/tests/test_rest_endpoints/test_system/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": true }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -43,5 +35,18 @@ }, "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_users/no_users.json b/tests/test_rest_endpoints/test_users/no_users.json index e5efe86..5929a79 100644 --- a/tests/test_rest_endpoints/test_users/no_users.json +++ b/tests/test_rest_endpoints/test_users/no_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -50,5 +42,18 @@ "ssh-rsa KEY test@pc" ], "users": [ - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_users/one_user.json b/tests/test_rest_endpoints/test_users/one_user.json index 5df2108..6c553bc 100644 --- a/tests/test_rest_endpoints/test_users/one_user.json +++ b/tests/test_rest_endpoints/test_users/one_user.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -57,5 +49,18 @@ "ssh-rsa KEY user1@pc" ] } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_users/some_users.json b/tests/test_rest_endpoints/test_users/some_users.json index 569253a..df6380a 100644 --- a/tests/test_rest_endpoints/test_users/some_users.json +++ b/tests/test_rest_endpoints/test_users/some_users.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -67,5 +59,18 @@ "username": "user3", "hashedPassword": "HASHED_PASSWORD_3" } - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file diff --git a/tests/test_rest_endpoints/test_users/undefined.json b/tests/test_rest_endpoints/test_users/undefined.json index 7b2cf8b..c1691ea 100644 --- a/tests/test_rest_endpoints/test_users/undefined.json +++ b/tests/test_rest_endpoints/test_users/undefined.json @@ -1,9 +1,4 @@ { - "backblaze": { - "accountId": "ID", - "accountKey": "KEY", - "bucket": "selfprivacy" - }, "api": { "token": "TEST_TOKEN", "enableSwagger": false @@ -11,9 +6,6 @@ "bitwarden": { "enable": false }, - "cloudflare": { - "apiKey": "TOKEN" - }, "databasePassword": "PASSWORD", "domain": "test.tld", "hashedMasterPassword": "HASHED_PASSWORD", @@ -48,5 +40,18 @@ "timezone": "Europe/Moscow", "sshKeys": [ "ssh-rsa KEY test@pc" - ] + ], + "dns": { + "provider": "CLOUDFLARE", + "apiKey": "TOKEN" + }, + "server": { + "provider": "HETZNER" + }, + "backup": { + "provider": "BACKBLAZE", + "accountId": "ID", + "accountKey": "KEY", + "bucket": "selfprivacy" + } } \ No newline at end of file