├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── Makefile ├── README.md ├── magic_admin ├── __init__.py ├── config.py ├── error.py ├── http_client.py ├── magic.py ├── resources │ ├── __init__.py │ ├── base.py │ ├── token.py │ ├── user.py │ └── wallet.py ├── response.py ├── utils │ ├── __init__.py │ ├── did_token.py │ ├── http.py │ └── time.py └── version.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── testing ├── __init__.py └── data │ ├── __init__.py │ └── did_token.py ├── tests ├── __init__.py ├── integration │ ├── __init__.py │ ├── magic_test.py │ └── resources │ │ ├── __init__.py │ │ └── token_test.py └── unit │ ├── __init__.py │ ├── config_test.py │ ├── error_test.py │ ├── http_client_test.py │ ├── magic_test.py │ ├── resources │ ├── __init__.py │ ├── base_test.py │ ├── token_test.py │ └── user_test.py │ ├── response_test.py │ └── utils │ ├── __init__.py │ ├── did_token_test.py │ ├── http_test.py │ └── time_test.py └── tox.ini /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Codeowners used to ensure Magic full-time employees review all PRs 2 | * @magiclabs/magic-engineering 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Use this template to report a bug. 4 | title: "[DESCRIPTIVE BUG NAME]" 5 | labels: 🐛 Bug Report, 🔍 Needs Triage 6 | --- 7 | 8 | ### ✅ Prerequisites 9 | 10 | - [ ] Did you perform a cursory search of open issues? Is this bug already reported elsewhere? 11 | - [ ] Are you running the latest SDK version? 12 | - [ ] Are you reporting to the correct repository (`magic-admin-python`)? 13 | 14 | ### 🐛 Description 15 | 16 | [Description of the bug.] 17 | 18 | ### 🧩 Steps to Reproduce 19 | 20 | 1. [First Step] 21 | 2. [Second Step] 22 | 3. [and so on...] 23 | 24 | ### 🤔 Expected behavior 25 | 26 | [What you expected to happen?] 27 | 28 | ### 😮 Actual behavior 29 | 30 | [What actually happened? Please include any error stack traces you encounter.] 31 | 32 | ### 💻 Code Sample 33 | 34 | [If possible, please provide a code repository, gist, code snippet or sample files to reproduce the issue.] 35 | 36 | ### 🌎 Environment 37 | 38 | | Software | Version(s) | 39 | | ------------------- | ---------- | 40 | | `magic-admin-python`| | 41 | | `python` | | 42 | | Operating System | | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Use this template to request a new feature. 4 | title: "[DESCRIPTIVE FEATURE NAME]" 5 | labels: ✨Feature Request 6 | --- 7 | 8 | ### ✅ Prerequisites 9 | 10 | - [ ] Did you perform a cursory search of open issues? Is this feature already requested elsewhere? 11 | - [ ] Are you reporting to the correct repository (`magic-admin-python`)? 12 | 13 | ### ✨ Feature Request 14 | 15 | [Description of the feature.] 16 | 17 | ## 🧩 Context 18 | 19 | [Explain any additional context or rationale for this feature. What are you trying to accomplish?] 20 | 21 | ## 💻 Examples 22 | 23 | [Do you have any example(s) for the requested feature? If so, describe/demonstrate your example(s) here.] 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Use this template to request help or ask a question. 4 | title: "[WHAT'S YOUR QUESTION?]" 5 | labels: ❓Question 6 | --- 7 | 8 | ### ✅ Prerequisites 9 | 10 | - [ ] Did you perform a cursory search of open issues? Is this question already asked elsewhere? 11 | - [ ] Are you reporting to the correct repository (`magic-admin-sdk`)? 12 | 13 | ### ❓ Question 14 | 15 | [Ask your question here, please be as detailed as possible!] 16 | 17 | ### 🌎 Environment 18 | 19 | | Software | Version(s) | 20 | | ------------------- | ---------- | 21 | | `magic-admin-python`| | 22 | | `python` | | 23 | | Operating System | | 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### 📦 Pull Request 2 | 3 | [Provide a general summary of the pull request here.] 4 | 5 | ### 🗜 Versioning 6 | 7 | (Check _one!_) 8 | 9 | - [ ] Patch: Bug Fix? 10 | - [ ] Minor: New Feature? 11 | - [ ] Major: Breaking Change? 12 | 13 | ### ✅ Fixed Issues 14 | 15 | - [List any fixed issues here like: Fixes #XXXX] 16 | 17 | ### 🚨 Test instructions 18 | 19 | [Describe any additional context required to test the PR/feature/bug fix.] 20 | 21 | ### ⚠️ Update `CHANGELOG.md` 22 | 23 | - [ ] I have updated the `Upcoming Changes` section of `CHANGELOG.md` with context related to this Pull Request. 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | day: "tuesday" 13 | commit-message: 14 | prefix: "[FIX]" 15 | prefix-development: "[CHORE]" 16 | include: scope 17 | # Fetch and update latest `github-actions` pkgs 18 | - package-ecosystem: github-actions 19 | directory: '/' 20 | schedule: 21 | interval: "weekly" 22 | day: "tuesday" 23 | commit-message: 24 | prefix: "[FIX]" 25 | prefix-development: "[CHORE]" 26 | include: scope 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | concurrency: 3 | group: ci-${{ github.ref }} 4 | cancel-in-progress: true 5 | on: 6 | push: 7 | branches: 8 | - "master" 9 | pull_request: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-20.04 14 | name: Run tests 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.6' 21 | cache: 'pip' 22 | 23 | - name: Install Dependencies 24 | run: pip install -r requirements.txt -r requirements-dev.txt 25 | 26 | - name: Run tests 27 | run: | 28 | make development 29 | source virtualenv_run/bin/activate 30 | make test 31 | 32 | security: 33 | runs-on: ubuntu-20.04 34 | name: Run style/security checks 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | - uses: actions/setup-python@v4 40 | with: 41 | python-version: '3.6' 42 | cache: 'pip' 43 | 44 | - name: Install Dependencies 45 | run: pip install -r requirements.txt -r requirements-dev.txt 46 | 47 | - name: Safety Check 48 | shell: bash 49 | run: | 50 | make development 51 | source virtualenv_run/bin/activate 52 | pip install -U safety 53 | safety check -i 44610 -i 51499 -i 51457 -i 39253 -i 44634 -i 50473 -i 52495 -i 53269 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.cfg 5 | !.isort.cfg 6 | !setup.cfg 7 | __pycache__/* 8 | .cache/* 9 | .*.swp 10 | .DS_Store 11 | .idea/ 12 | 13 | # Package files 14 | *.egg 15 | *.eggs/ 16 | .installed.cfg 17 | *.egg-info 18 | 19 | # Unittest and coverage 20 | .coverage 21 | .tox 22 | coverage.xml 23 | .pytest_cache/ 24 | 25 | # Build and docs folder/files 26 | build/* 27 | dist/* 28 | sdist/* 29 | cover/* 30 | 31 | # Per-project virtualenvs 32 | virtualenv_run*/ 33 | .virtualenv_run_test/ 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.5.0 4 | hooks: 5 | - id: flake8 6 | language_version: python3.6 7 | args: [ 8 | --max-line-length=100, 9 | # We did some funky thing in __init__.py, skip them for now. 10 | --exclude=*__init__.py 11 | ] 12 | - id: trailing-whitespace 13 | language_version: python3.6 14 | - id: end-of-file-fixer 15 | language_version: python3.6 16 | - id: check-merge-conflict 17 | language_version: python3.6 18 | - id: requirements-txt-fixer 19 | language_version: python3.6 20 | - id: name-tests-test 21 | language_version: python3.6 22 | - id: double-quote-string-fixer 23 | language_version: python3.6 24 | - id: forbid-new-submodules 25 | language_version: python3.6 26 | - id: check-yaml 27 | language_version: python3.6 28 | files: (\.(yaml|yml|eyaml))$ 29 | - id: check-json 30 | files: \.(jshintrc|json)$ 31 | - repo: https://github.com/pre-commit/mirrors-autopep8 32 | rev: v1.5.1 33 | hooks: 34 | - id: autopep8 35 | language_version: python3.6 36 | - repo: https://github.com/asottile/add-trailing-comma 37 | rev: v2.0.1 38 | hooks: 39 | - id: add-trailing-comma 40 | - repo: https://github.com/asottile/reorder_python_imports 41 | rev: v2.2.0 42 | hooks: 43 | - id: reorder-python-imports 44 | language_version: python3.6 45 | - repo: https://github.com/asottile/pyupgrade 46 | rev: v2.1.1 47 | hooks: 48 | - id: pyupgrade 49 | args: 50 | - --py3-plus 51 | language_version: python3.6 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Upcoming Changes 2 | 3 | #### Fixed 4 | 5 | - ... 6 | 7 | #### Changed 8 | 9 | - ... 10 | 11 | #### Added 12 | 13 | - ... 14 | 15 | ## `1.0.0` - 07/05/2023 16 | 17 | #### Added 18 | 19 | - PR-#87: Add Magic Connect Admin SDK support for Token Resource [#111](https://github.com/magiclabs/magic-admin-js/pull/111) ([@magic-ravi](https://github.com/magic-ravi)) 20 | - [Security Enhancement]: Validate `aud` using Magic client ID. 21 | - Pull client ID from Magic servers if not provided in constructor. 22 | 23 | 24 | ## `0.3.3` - 05/02/2023 25 | 26 | #### Changed 27 | 28 | - PR-#77: Removing NFT functionality, clients will interact with the NFT API directly via API calls. 29 | 30 | 31 | ## `0.3.2` - 03/21/2023 32 | 33 | #### Added 34 | 35 | - PR-#69: Patch bad formatting of request 36 | 37 | ## `0.3.1` - 03/21/2023 38 | 39 | #### Added 40 | 41 | - PR-#67: Patch module not found fixed for new nft module 42 | 43 | ## `0.3.0` - 03/20/2023 44 | 45 | #### Added 46 | 47 | - PR-#66: Create paths for minting an NFT through magic delivery service. 48 | 49 | ## `0.2.0` - 1/04/2023 50 | 51 | #### Added 52 | 53 | - PR-#50: Split up DIDTokenError into DIDTokenExpired, DIDTokenMalformed, and DIDTokenInvalid. 54 | 55 | ## `0.1.0` - 11/30/2022 56 | 57 | #### Added 58 | 59 | - PR-#46: Support mult-chain wallets in get_metadata calls 60 | 61 | ## `0.0.5` - 06/23/2021 62 | 63 | #### Fixed 64 | 65 | - Relax dependency version requirement constraints 66 | 67 | ## `0.0.4` - 04/23/2020 68 | 69 | #### Changed 70 | 71 | - PR-#14: Update external document link. 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via an **issue**. This can be a feature request or a bug report. After a maintainer has triaged your issue, you are welcome to collaborate on a pull request. If your change is small or uncomplicated, you are welcome to open an issue and pull request simultaneously. 4 | 5 | Please note we have a **code of conduct**, please follow it in all your interactions with the project. 6 | 7 | ## Setting up for Local Development 8 | 9 | 1. Fork this repostiory. 10 | 2. Clone your fork. 11 | 3. Create a new branch in your local repository with the following pattern: 12 | 13 | - For bug fixes: `bug/#[issue_number]/[descriptive_bug_name]` 14 | - For features: `feature/#[issue_number]/[descriptive_feature_name]` 15 | - For chores/the rest: `chore/[descriptive_chore_name]` 16 | 17 | 4. Install dependencies: `make development` 18 | 5. Source the virtualenv: `source virtualenv_run/bin/activate` 19 | 6. Start building for development 20 | 21 | ## Opening a Pull Request 22 | 23 | 1. Update the **`Upcoming Changes`** section of [`CHANGELOG.md`](./CHANGELOG.md) with your fixes, changes, or additions. A maintainer will label your changes with a version number and release date once they are published. 24 | 2. Open a pull request from your fork/branch to the upstream `master` branch of _this_ repository. 25 | 3. A maintainer will review your code changes and offer feedback or suggestions if necessary. Once your changes are approved, a maintainer will merge the pull request for you and publish a release. 26 | 27 | ## Contributor Covenant Code of Conduct 28 | 29 | ### Our Pledge 30 | 31 | We as members, contributors, and leaders pledge to make participation in our 32 | community a harassment-free experience for everyone, regardless of age, body 33 | size, visible or invisible disability, ethnicity, sex characteristics, gender 34 | identity and expression, level of experience, education, socio-economic status, 35 | nationality, personal appearance, race, religion, or sexual identity 36 | and orientation. 37 | 38 | We pledge to act and interact in ways that contribute to an open, welcoming, 39 | diverse, inclusive, and healthy community. 40 | 41 | ### Our Standards 42 | 43 | Examples of behavior that contributes to a positive environment for our 44 | community include: 45 | 46 | - Demonstrating empathy and kindness toward other people 47 | - Being respectful of differing opinions, viewpoints, and experiences 48 | - Giving and gracefully accepting constructive feedback 49 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 50 | - Focusing on what is best not just for us as individuals, but for the overall community 51 | 52 | Examples of unacceptable behavior include: 53 | 54 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 55 | - Trolling, insulting or derogatory comments, and personal or political attacks 56 | - Public or private harassment 57 | - Publishing others' private information, such as a physical or email address, without their explicit permission 58 | - Other conduct which could reasonably be considered inappropriate in a professional setting 59 | 60 | ### Enforcement Responsibilities 61 | 62 | Community leaders are responsible for clarifying and enforcing our standards of 63 | acceptable behavior and will take appropriate and fair corrective action in 64 | response to any behavior that they deem inappropriate, threatening, offensive, 65 | or harmful. 66 | 67 | Community leaders have the right and responsibility to remove, edit, or reject 68 | comments, commits, code, wiki edits, issues, and other contributions that are 69 | not aligned to this Code of Conduct, and will communicate reasons for moderation 70 | decisions when appropriate. 71 | 72 | ### Scope 73 | 74 | This Code of Conduct applies within all community spaces, and also applies when 75 | an individual is officially representing the community in public spaces. 76 | Examples of representing our community include using an official e-mail address, 77 | posting via an official social media account, or acting as an appointed 78 | representative at an online or offline event. 79 | 80 | ### Enforcement 81 | 82 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 83 | reported to the community leaders responsible for enforcement at [support@magic.link](mailto:support@magic.link). 84 | All complaints will be reviewed and investigated promptly and fairly. 85 | 86 | All community leaders are obligated to respect the privacy and security of the 87 | reporter of any incident. 88 | 89 | ### Enforcement Guidelines 90 | 91 | Community leaders will follow these Community Impact Guidelines in determining 92 | the consequences for any action they deem in violation of this Code of Conduct: 93 | 94 | #### 1. Correction 95 | 96 | **Community Impact**: Use of inappropriate language or other behavior deemed 97 | unprofessional or unwelcome in the community. 98 | 99 | **Consequence**: A private, written warning from community leaders, providing 100 | clarity around the nature of the violation and an explanation of why the 101 | behavior was inappropriate. A public apology may be requested. 102 | 103 | #### 2. Warning 104 | 105 | **Community Impact**: A violation through a single incident or series 106 | of actions. 107 | 108 | **Consequence**: A warning with consequences for continued behavior. No 109 | interaction with the people involved, including unsolicited interaction with 110 | those enforcing the Code of Conduct, for a specified period of time. This 111 | includes avoiding interactions in community spaces as well as external channels 112 | like social media. Violating these terms may lead to a temporary or 113 | permanent ban. 114 | 115 | #### 3. Temporary Ban 116 | 117 | **Community Impact**: A serious violation of community standards, including 118 | sustained inappropriate behavior. 119 | 120 | **Consequence**: A temporary ban from any sort of interaction or public 121 | communication with the community for a specified period of time. No public or 122 | private interaction with the people involved, including unsolicited interaction 123 | with those enforcing the Code of Conduct, is allowed during this period. 124 | Violating these terms may lead to a permanent ban. 125 | 126 | #### 4. Permanent Ban 127 | 128 | **Community Impact**: Demonstrating a pattern of violation of community 129 | standards, including sustained inappropriate behavior, harassment of an 130 | individual, or aggression toward or disparagement of classes of individuals. 131 | 132 | **Consequence**: A permanent ban from any sort of public interaction within 133 | the community. 134 | 135 | ### Attribution 136 | 137 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), 138 | version 2.0, available at 139 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 140 | 141 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 142 | enforcement ladder](https://github.com/mozilla/diversity). 143 | 144 | For answers to common questions about this code of conduct, see the FAQ at 145 | https://www.contributor-covenant.org/faq. Translations are available at 146 | https://www.contributor-covenant.org/translations. 147 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Fortmatic Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: production 2 | production: virtualenv_run install_prod_requirements 3 | 4 | .PHONY: development 5 | development: virtualenv_run install_prod_requirements install_dev_requirements install-hooks 6 | 7 | .PHONY: virtualenv_run 8 | virtualenv_run: 9 | virtualenv -p python3.6 virtualenv_run 10 | virtualenv_run/bin/pip install --upgrade pip 11 | 12 | .PHONY: install_prod_requirements 13 | install_prod_requirements: virtualenv_run 14 | virtualenv_run/bin/pip install -r requirements.txt 15 | 16 | .PHONY: install_dev_requirements 17 | install_dev_requirements: virtualenv_run 18 | virtualenv_run/bin/pip install -r requirements-dev.txt 19 | 20 | .PHONY: install-hooks 21 | install-hooks: virtualenv_run install_dev_requirements 22 | ./virtualenv_run/bin/pre-commit install -f --install-hooks 23 | 24 | .PHONY: test 25 | test: 26 | tox 27 | 28 | clean-cache: 29 | find . -name '__pycache__' | xargs rm -rf 30 | find . -name '*.pyc' -delete 31 | 32 | clean-build: 33 | rm -rf build/ 34 | rm -rf dist/ 35 | rm -rf sdist/ 36 | rm -rf *.egg 37 | rm -rf *.eggs/ 38 | rm -rf *.egg-info 39 | 40 | .PHONY: clean 41 | clean: clean-cache clean-build 42 | rm -rf virtualenv_run/ 43 | rm -rf .virtualenv_run_test/ 44 | rm -rf .pytest_cache/ 45 | rm -rf .tox/ 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magic Admin Python SDK 2 | 3 | The Magic Admin Python SDK provides convenient ways for developers to interact with Magic API endpoints and an array of utilities to handle [DID Token](https://magic.link/docs/auth/introduction/decentralized-id). 4 | 5 | ## Table of Contents 6 | 7 | * [Documentation](#documentation) 8 | * [Quick Start](#quick-start) 9 | * [Development](#development) 10 | * [Changelog](#changelog) 11 | * [License](#license) 12 | 13 | ## Documentation 14 | See the [Magic doc](https://magic.link/docs/auth/api-reference/server-side-sdks/python)! 15 | 16 | ## Installation 17 | You can directly install the SDK with: 18 | 19 | pip: 20 | 21 | ``` 22 | pip install magic-admin 23 | ``` 24 | 25 | conda: 26 | 27 | ``` 28 | conda install magic-admin 29 | ``` 30 | 31 | ### Prerequisites 32 | 33 | - Python 3.6 34 | 35 | **Note**: This package has only been tested with `Python 3.6`. `Python 3.7` and `Python 3.8` have not been tested yet. We will get to it very soon. Support for `Python 2.7+` will not be actively worked on. If you are interested using this package with earlier versions of Python, please create a ticket and let us know :) 36 | 37 | ## Quick Start 38 | Before you start, you will need an API secret key. You can get one from the [Magic Dashboard](https://dashboard.magic.link/). Once you have the API secret key, you can instantiate a Magic object. 39 | 40 | ``` 41 | from magic_admin import Magic 42 | 43 | magic = Magic(api_secret_key='') 44 | 45 | magic.Token.validate('DID_TOKEN') 46 | 47 | # Read the docs to learn more! 🚀 48 | ``` 49 | 50 | Optionally if you would like, you can load the API secret key from the environment variable, `MAGIC_API_SECRET_KEY`. 51 | 52 | ``` 53 | # Set the env variable `MAGIC_API_SECRET_KEY`. 54 | 55 | magic = Magic() 56 | ``` 57 | 58 | **Note**: The argument passed to the `Magic(...)` object takes precedence over the environment variable. 59 | 60 | ### Configure Network Strategy 61 | The `Magic` object also takes in `retries`, `timeout` and `backoff_factor` as optional arguments at the object instantiation time so you can override those values for your application setup. 62 | 63 | ``` 64 | magic = Magic(retries=5, timeout=10, backoff_factor=0.03) 65 | ``` 66 | 67 | ## Development 68 | We would love to have you contributing to this SDK. To get started, you can clone this repository and create a virtualenv. 69 | 70 | ``` 71 | make development 72 | ``` 73 | 74 | This will create a virtualenv for all the local development dependencies that the SDK will needs. 75 | 76 | Once it is done, you can `source` the virtualenv. It makes your local development easier! 77 | 78 | ``` 79 | source virtualenv_run/bin/activate 80 | ``` 81 | 82 | To make sure your new code works with the existing SDK, run the test against the current supported Python versions. 83 | 84 | ``` 85 | make test 86 | ``` 87 | 88 | To clean up existing virtualenv, tox log and pytest cache, do a 89 | 90 | ``` 91 | make clean 92 | ``` 93 | 94 | This repository is installed with [pre-commit](https://pre-commit.com/). All of the pre-commit hooks are run automatically with every new commit. This is to keep the codebase styling and format consistent. 95 | 96 | You can also run the pre-commit manually. You can find all the pre-commit hooks [here](.pre-commit-config.yaml). 97 | 98 | ``` 99 | pre-commit run 100 | ``` 101 | 102 | Please also see our [CONTRIBUTING](CONTRIBUTING.md) guide for other information. 103 | 104 | ## Changelog 105 | See [Changelog](CHANGELOG.md) 106 | 107 | ## License 108 | See [License](LICENSE.txt) 109 | -------------------------------------------------------------------------------- /magic_admin/__init__.py: -------------------------------------------------------------------------------- 1 | from magic_admin.magic import Magic 2 | 3 | 4 | # Magic API secret key. 5 | api_secret_key = None 6 | 7 | # A grace period time in second applied to the nbf field for token validation. 8 | did_token_nbf_grace_period_s = 300 9 | -------------------------------------------------------------------------------- /magic_admin/config.py: -------------------------------------------------------------------------------- 1 | base_url = 'https://api.magic.link' 2 | 3 | api_secret_api_key_missing_message = 'API secret key is missing. Please specify ' \ 4 | 'an API secret key when you instantiate the `Magic(api_secret_key=)` ' \ 5 | 'object or use the environment variable, `MAGIC_API_SECRET_KEY`. You can ' \ 6 | 'get your API secret key from https://dashboard.magic.link. If you are having ' \ 7 | 'trouble, please don\'t hesitate to reach out to us at support@magic.link' 8 | -------------------------------------------------------------------------------- /magic_admin/error.py: -------------------------------------------------------------------------------- 1 | class MagicError(Exception): 2 | 3 | def __init__(self, message=None): 4 | super().__init__(message) 5 | self._message = message 6 | 7 | def __str__(self): 8 | return self._message or '' 9 | 10 | def __repr__(self): 11 | return '{error_class}(message={message!r})'.format( 12 | error_class=self.__class__.__name__, 13 | message=self._message, 14 | ) 15 | 16 | def to_dict(self): 17 | return {'message': str(self)} 18 | 19 | 20 | class DIDTokenInvalid(MagicError): 21 | pass 22 | 23 | 24 | class DIDTokenMalformed(MagicError): 25 | pass 26 | 27 | 28 | class DIDTokenExpired(MagicError): 29 | pass 30 | 31 | 32 | class APIConnectionError(MagicError): 33 | pass 34 | 35 | 36 | class RequestError(MagicError): 37 | 38 | def __init__( 39 | self, 40 | message=None, 41 | http_status=None, 42 | http_code=None, 43 | http_resp_data=None, 44 | http_message=None, 45 | http_error_code=None, 46 | http_request_params=None, 47 | http_request_data=None, 48 | http_method=None, 49 | ): 50 | super().__init__(message) 51 | self.http_status = http_status 52 | self.http_code = http_code 53 | self.http_resp_data = http_resp_data 54 | self.http_message = http_message 55 | self.http_error_code = http_error_code 56 | self.http_request_params = http_request_params 57 | self.http_request_data = http_request_data 58 | self.http_method = http_method 59 | 60 | def __repr__(self): 61 | return '{error_class}(message={message!r}, ' \ 62 | 'http_error_code={http_error_code}, ' \ 63 | 'http_code={http_code}).'.format( 64 | error_class=self.__class__.__name__, 65 | message=self._message or None, 66 | http_error_code=self.http_error_code or None, 67 | http_code=self.http_code or None, 68 | ) 69 | 70 | def to_dict(self): 71 | _dict = super().to_dict() 72 | for attr in self.__dict__: 73 | if attr.startswith('http_'): 74 | _dict[attr] = self.__dict__[attr] 75 | 76 | return _dict 77 | 78 | 79 | class RateLimitingError(RequestError): 80 | pass 81 | 82 | 83 | class BadRequestError(RequestError): 84 | pass 85 | 86 | 87 | class AuthenticationError(RequestError): 88 | pass 89 | 90 | 91 | class ForbiddenError(RequestError): 92 | pass 93 | 94 | 95 | class APIError(RequestError): 96 | pass 97 | -------------------------------------------------------------------------------- /magic_admin/http_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import platform 3 | 4 | from requests import Session 5 | from requests.adapters import HTTPAdapter 6 | from requests.packages.urllib3.util.retry import Retry 7 | 8 | import magic_admin 9 | from magic_admin import version 10 | from magic_admin.config import api_secret_api_key_missing_message 11 | from magic_admin.config import base_url 12 | from magic_admin.error import APIConnectionError 13 | from magic_admin.error import APIError 14 | from magic_admin.error import AuthenticationError 15 | from magic_admin.error import BadRequestError 16 | from magic_admin.error import ForbiddenError 17 | from magic_admin.error import RateLimitingError 18 | from magic_admin.response import MagicResponse 19 | 20 | 21 | class RequestsClient: 22 | 23 | def __init__(self, retries, timeout, backoff_factor): 24 | self._retries = retries 25 | self._timeout = timeout 26 | self._backoff_factor = backoff_factor 27 | 28 | self._setup_request_session() 29 | 30 | @staticmethod 31 | def _get_platform_info(): 32 | platform_info = {} 33 | 34 | for attr, func in [ 35 | ['platform', platform.platform], 36 | ['language_version', platform.python_version], 37 | ['uname', platform.uname], 38 | ]: 39 | try: 40 | val = str(func()) 41 | except Exception as e: 42 | val = '<{}>'.format(str(e)) 43 | 44 | platform_info[attr] = val 45 | 46 | return platform_info 47 | 48 | def _setup_request_session(self): 49 | """Take advantage of the ``requets.Session``. If client is making several 50 | requests to the same host, the underlying TCP connection will be reused, 51 | which can result in a significant performance increase. 52 | """ 53 | self.http = Session() 54 | self.http.mount( 55 | base_url, 56 | HTTPAdapter( 57 | max_retries=Retry( 58 | total=self._retries, 59 | backoff_factor=self._backoff_factor, 60 | ), 61 | ), 62 | ) 63 | 64 | def _get_request_headers(self): 65 | user_agent = { 66 | 'language': 'python', 67 | 'sdk_version': version.VERSION, 68 | 'publisher': 'magic', 69 | 'http_lib': self.__class__.__name__, 70 | **self._get_platform_info(), 71 | } 72 | 73 | if magic_admin.api_secret_key is None: 74 | raise AuthenticationError(api_secret_api_key_missing_message) 75 | 76 | return { 77 | 'X-Magic-Secret-Key': magic_admin.api_secret_key, 78 | 'User-Agent': json.dumps(user_agent), 79 | } 80 | 81 | def request(self, method, url, params=None, data=None): 82 | try: 83 | api_resp = self.http.request( 84 | method, 85 | url, 86 | params=params, 87 | # Requests auto-converts this to JSON and add content-type 88 | # `application/json`. 89 | json=data, 90 | headers=self._get_request_headers(), 91 | timeout=self._timeout, 92 | ) 93 | except Exception as e: 94 | return self._handle_request_error(e) 95 | 96 | return self._parse_and_convert_to_api_response( 97 | api_resp, 98 | params, 99 | data, 100 | ) 101 | 102 | def _parse_and_convert_to_api_response(self, resp, request_params, request_data): 103 | status_code = resp.status_code 104 | 105 | if 200 <= status_code < 300: 106 | return MagicResponse(resp.content, resp.json(), status_code) 107 | 108 | if status_code == 429: 109 | error_class = RateLimitingError 110 | elif status_code == 400: 111 | error_class = BadRequestError 112 | elif status_code == 401: 113 | error_class = AuthenticationError 114 | elif status_code == 403: 115 | error_class = ForbiddenError 116 | else: 117 | error_class = APIError 118 | 119 | resp_data = resp.json() 120 | raise error_class( 121 | http_status=resp_data.get('status'), 122 | http_code=status_code, 123 | http_resp_data=resp_data.get('data'), 124 | http_message=resp_data.get('message'), 125 | http_error_code=resp_data.get('error_code'), 126 | http_request_params=request_params, 127 | http_request_data=request_data, 128 | http_method=resp.request.method, 129 | ) 130 | 131 | def _handle_request_error(self, e): 132 | message = 'Unexpected error thrown while communicating to Magic. ' \ 133 | 'Please reach out to support@magic.link if the problem continues. ' \ 134 | 'Error message: {error_class} was raised - {error_message}'.format( 135 | error_class=e.__class__.__name__, 136 | error_message=str(e) or 'no error message.', 137 | ) 138 | 139 | raise APIConnectionError(message) 140 | -------------------------------------------------------------------------------- /magic_admin/magic.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import magic_admin 4 | from magic_admin.config import api_secret_api_key_missing_message 5 | from magic_admin.config import base_url 6 | from magic_admin.error import AuthenticationError 7 | from magic_admin.http_client import RequestsClient 8 | from magic_admin.resources.base import ResourceComponent 9 | 10 | 11 | RETRIES = 3 12 | TIMEOUT = 10 13 | BACKOFF_FACTOR = 0.02 14 | 15 | 16 | class Magic: 17 | 18 | v1_client_info = base_url + '/v1/admin/client/get' 19 | 20 | def __getattr__(self, attribute_name): 21 | try: 22 | return getattr(self._resource, attribute_name) 23 | except AttributeError: 24 | pass 25 | 26 | return super().__getattribute__(attribute_name) 27 | 28 | def __init__( 29 | self, 30 | api_secret_key=None, 31 | client_id=None, 32 | retries=RETRIES, 33 | timeout=TIMEOUT, 34 | backoff_factor=BACKOFF_FACTOR, 35 | ): 36 | self._resource = ResourceComponent() 37 | 38 | self._resource.setup_request_client(retries, timeout, backoff_factor) 39 | self._set_api_secret_key(api_secret_key) 40 | init_requests_client = RequestsClient(retries, timeout, backoff_factor) 41 | magic_admin.client_id = client_id or \ 42 | init_requests_client.request('get', self.v1_client_info).data['client_id'] 43 | 44 | def _set_api_secret_key(self, api_secret_key): 45 | magic_admin.api_secret_key = api_secret_key or os.environ.get( 46 | 'MAGIC_API_SECRET_KEY', 47 | ) 48 | 49 | if magic_admin.api_secret_key is None: 50 | raise AuthenticationError(api_secret_api_key_missing_message) 51 | -------------------------------------------------------------------------------- /magic_admin/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from magic_admin.resources.token import Token 2 | from magic_admin.resources.user import User 3 | from magic_admin.resources.wallet import WalletType 4 | -------------------------------------------------------------------------------- /magic_admin/resources/base.py: -------------------------------------------------------------------------------- 1 | from magic_admin.config import base_url 2 | from magic_admin.http_client import RequestsClient 3 | 4 | 5 | class ResourceMeta(type): 6 | 7 | def __init__(cls, name, bases, cls_dict): 8 | if not hasattr(cls, '_registry'): 9 | cls._registry = {} 10 | else: 11 | cls._registry[name] = cls() 12 | 13 | super().__init__(name, bases, cls_dict) 14 | 15 | 16 | class ResourceComponent(metaclass=ResourceMeta): 17 | 18 | _base_url = base_url 19 | 20 | def __getattr__(self, resource_name): 21 | if resource_name in self._registry: 22 | return self._registry[resource_name] 23 | else: 24 | raise AttributeError( 25 | '{object_name} has no attribute \'{resource_name}\''.format( 26 | object_name=self.__class__.__name__, 27 | resource_name=resource_name, 28 | ), 29 | ) 30 | 31 | def setup_request_client(self, retries, timeout, backoff_factor): 32 | _request_client = RequestsClient(retries, timeout, backoff_factor) 33 | 34 | for resource in self._registry.values(): 35 | setattr(resource, '_request_client', _request_client) 36 | 37 | def _construct_url(self, url_path): 38 | return '{base_url}{url_path}'.format( 39 | base_url=self._base_url, 40 | url_path=url_path, 41 | ) 42 | 43 | def request(self, method, url_path, params=None, data=None): 44 | return self._request_client.request( 45 | method.lower(), 46 | self._construct_url(url_path), 47 | params=params, 48 | data=data, 49 | ) 50 | -------------------------------------------------------------------------------- /magic_admin/resources/token.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from eth_account.messages import defunct_hash_message 5 | from web3.auto import w3 6 | 7 | import magic_admin 8 | from magic_admin.error import DIDTokenExpired 9 | from magic_admin.error import DIDTokenInvalid 10 | from magic_admin.error import DIDTokenMalformed 11 | from magic_admin.resources.base import ResourceComponent 12 | from magic_admin.utils.did_token import parse_public_address_from_issuer 13 | from magic_admin.utils.time import apply_did_token_nbf_grace_period 14 | from magic_admin.utils.time import epoch_time_now 15 | 16 | 17 | EXPECTED_DID_TOKEN_CONTENT_LENGTH = 2 18 | 19 | 20 | class Token(ResourceComponent): 21 | 22 | required_fields = frozenset([ 23 | 'iat', 24 | 'ext', 25 | 'nbf', 26 | 'iss', 27 | 'sub', 28 | 'aud', 29 | 'tid', 30 | ]) 31 | 32 | @classmethod 33 | def _check_required_fields(cls, claim): 34 | """ 35 | Args: 36 | claim (dict): A dict that represents the claim portion of the DID 37 | token. 38 | 39 | Returns: 40 | None. 41 | """ 42 | missing_fields = [] 43 | for field in cls.required_fields: 44 | if field not in claim: 45 | missing_fields.append(field) 46 | 47 | if missing_fields: 48 | raise DIDTokenMalformed( 49 | message='DID token is missing required field(s): {}'.format( 50 | sorted(missing_fields), 51 | ), 52 | ) 53 | 54 | @classmethod 55 | def decode(cls, did_token): 56 | """ 57 | Args: 58 | did_token (base64.str): Base64 encoded string. 59 | 60 | Raises: 61 | DIDTokenMalformed: If token format is invalid. 62 | 63 | Returns: 64 | proof (str): A signed message. 65 | claim (dict): A dict of unsigned message. 66 | """ 67 | try: 68 | decoded_did_token = json.loads( 69 | base64.urlsafe_b64decode(did_token).decode('utf-8'), 70 | ) 71 | except Exception as e: 72 | raise DIDTokenMalformed( 73 | message='DID token is malformed. It has to be a based64 encoded ' 74 | 'JSON serialized string. {err} ({msg}).'.format( 75 | err=e.__class__.__name__, 76 | msg=str(e) or '', 77 | ), 78 | ) 79 | 80 | if len(decoded_did_token) != EXPECTED_DID_TOKEN_CONTENT_LENGTH: 81 | raise DIDTokenMalformed( 82 | message='DID token is malformed. It has to have two parts ' 83 | '[proof, claim].', 84 | ) 85 | 86 | proof = decoded_did_token[0] 87 | 88 | try: 89 | claim = json.loads(decoded_did_token[1]) 90 | except Exception as e: 91 | raise DIDTokenMalformed( 92 | message='DID token is malformed. Given claim should be a JSON ' 93 | 'serialized string. {err} ({msg}).'.format( 94 | err=e.__class__.__name__, 95 | msg=str(e) or '', 96 | ), 97 | ) 98 | 99 | cls._check_required_fields(claim) 100 | 101 | return proof, claim 102 | 103 | @classmethod 104 | def get_issuer(cls, did_token): 105 | """ 106 | Args: 107 | did_token (base64.str): Base64 encoded string. 108 | 109 | Returns: 110 | issuer (str): Issuer (the signer, the "user"). This field is represented 111 | as a Decentralized Identifier populated with the user's Ethereum 112 | public key. 113 | """ 114 | _, claim = cls.decode(did_token) 115 | 116 | return claim['iss'] 117 | 118 | @classmethod 119 | def get_public_address(cls, did_token): 120 | """ 121 | Args: 122 | did_token (base64.str): Base64 encoded string. 123 | 124 | Returns: 125 | public_address (str): An Ethereum public key. 126 | """ 127 | return parse_public_address_from_issuer(cls.get_issuer(did_token)) 128 | 129 | @classmethod 130 | def validate(cls, did_token): 131 | """ 132 | Args: 133 | did_token (base64.str): Base64 encoded string. 134 | 135 | Raises: 136 | DIDTokenInvalid: If DID token fails the validation. 137 | DIDTokenExpired: If DID token has expired. 138 | 139 | Returns: 140 | None. 141 | """ 142 | proof, claim = cls.decode(did_token) 143 | 144 | if claim['ext'] is None: 145 | raise DIDTokenInvalid( 146 | message='Please check the "ext" field and regenerate a new token ' 147 | 'with a suitable value.', 148 | ) 149 | 150 | recovered_address = w3.eth.account.recoverHash( 151 | defunct_hash_message( 152 | text=json.dumps(claim, separators=(',', ':')), 153 | ), 154 | signature=proof, 155 | ) 156 | 157 | if recovered_address != cls.get_public_address(did_token): 158 | raise DIDTokenInvalid( 159 | message='Signature mismatch between "proof" and "claim". Please ' 160 | 'generate a new token with an intended issuer.', 161 | ) 162 | 163 | current_time_in_s = epoch_time_now() 164 | 165 | if current_time_in_s > claim['ext']: 166 | raise DIDTokenExpired( 167 | message='Given DID token has expired. Please generate a new one.', 168 | ) 169 | 170 | if current_time_in_s < apply_did_token_nbf_grace_period(claim['nbf']): 171 | raise DIDTokenInvalid( 172 | message='Given DID token cannot be used at this time. Please ' 173 | 'check the "nbf" field and regenerate a new token with a suitable ' 174 | 'value.', 175 | ) 176 | 177 | if claim['aud'] != magic_admin.client_id: 178 | raise DIDTokenInvalid( 179 | message='"aud" field does not match your client. Please check your secret key.', 180 | ) 181 | -------------------------------------------------------------------------------- /magic_admin/resources/user.py: -------------------------------------------------------------------------------- 1 | from magic_admin.resources.base import ResourceComponent 2 | from magic_admin.resources.wallet import WalletType 3 | from magic_admin.utils.did_token import construct_issuer_with_public_address 4 | 5 | 6 | class User(ResourceComponent): 7 | 8 | v1_user_info = '/v1/admin/auth/user/get' 9 | v2_user_logout = '/v2/admin/auth/user/logout' 10 | 11 | def get_metadata_by_issuer_and_wallet(self, issuer, wallet_type): 12 | return self.request( 13 | 'get', self.v1_user_info, params={'issuer': issuer, 'wallet_type': wallet_type}, 14 | ) 15 | 16 | def get_metadata_by_public_address_and_wallet(self, public_address, wallet_type): 17 | return self.get_metadata_by_issuer_and_wallet( 18 | construct_issuer_with_public_address(public_address), 19 | wallet_type, 20 | ) 21 | 22 | def get_metadata_by_token_and_wallet(self, did_token, wallet_type): 23 | return self.get_metadata_by_issuer_and_wallet(self.Token.get_issuer(did_token), wallet_type) 24 | 25 | def get_metadata_by_issuer(self, issuer): 26 | return self.get_metadata_by_issuer_and_wallet(issuer, WalletType.NONE) 27 | 28 | def get_metadata_by_public_address(self, public_address): 29 | return self.get_metadata_by_issuer( 30 | construct_issuer_with_public_address(public_address), 31 | ) 32 | 33 | def get_metadata_by_token(self, did_token): 34 | return self.get_metadata_by_issuer(self.Token.get_issuer(did_token)) 35 | 36 | def logout_by_issuer(self, issuer): 37 | return self.request('post', self.v2_user_logout, data={'issuer': issuer}) 38 | 39 | def logout_by_public_address(self, public_address): 40 | return self.logout_by_issuer( 41 | construct_issuer_with_public_address(public_address), 42 | ) 43 | 44 | def logout_by_token(self, did_token): 45 | return self.logout_by_issuer(self.Token.get_issuer(did_token)) 46 | -------------------------------------------------------------------------------- /magic_admin/resources/wallet.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class WalletType(Enum): 5 | ETH = 'ETH' 6 | HARMONY = 'HARMONY' 7 | ICON = 'ICON' 8 | FLOW = 'FLOW' 9 | TEZOS = 'TEZOS' 10 | ZILLIQA = 'ZILLIQA' 11 | POLKADOT = 'POLKADOT' 12 | SOLANA = 'SOLANA' 13 | AVAX = 'AVAX' 14 | ALGOD = 'ALGOD' 15 | COSMOS = 'COSMOS' 16 | CELO = 'CELO' 17 | BITCOIN = 'BITCOIN' 18 | NEAR = 'NEAR' 19 | HELIUM = 'HELIUM' 20 | CONFLUX = 'CONFLUX' 21 | TERRA = 'TERRA' 22 | TAQUITO = 'TAQUITO' 23 | ED = 'ED' 24 | HEDERA = 'HEDERA' 25 | NONE = 'NONE' 26 | ANY = 'ANY' 27 | -------------------------------------------------------------------------------- /magic_admin/response.py: -------------------------------------------------------------------------------- 1 | class MagicResponse: 2 | 3 | def __init__(self, content, resp_data, status_code): 4 | self.content = content 5 | self.status_code = status_code 6 | self.data = resp_data['data'] 7 | -------------------------------------------------------------------------------- /magic_admin/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/magic_admin/utils/__init__.py -------------------------------------------------------------------------------- /magic_admin/utils/did_token.py: -------------------------------------------------------------------------------- 1 | from magic_admin.error import DIDTokenMalformed 2 | 3 | 4 | def parse_public_address_from_issuer(issuer): 5 | """ 6 | Args: 7 | issuer (str): Issuer (the signer, the "user"). This field is represented 8 | as a Decentralized Identifier populated with the user's Ethereum 9 | public key. 10 | 11 | Returns: 12 | public_address (str): An Ethereum public key. 13 | """ 14 | try: 15 | return issuer.split(':')[2] 16 | except IndexError: 17 | raise DIDTokenMalformed( 18 | 'Given issuer ({}) is malformed. Please make sure it follows the ' 19 | '`did:method-name:method-specific-id` format.'.format(issuer), 20 | ) 21 | 22 | 23 | def construct_issuer_with_public_address(public_address): 24 | return 'did:ethr:{}'.format(public_address) 25 | -------------------------------------------------------------------------------- /magic_admin/utils/http.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | AUTHORIZATION_PATTERN = r'Bearer (?P.+)' 4 | 5 | 6 | def null_safe(value): 7 | if value is None or value in ['null', 'none', 'None', '']: 8 | return None 9 | 10 | return value 11 | 12 | 13 | def parse_authorization_header_value(header_value): 14 | m = re.match(AUTHORIZATION_PATTERN, header_value) 15 | 16 | if m is None: 17 | return None 18 | 19 | return null_safe(m.group('token')) 20 | -------------------------------------------------------------------------------- /magic_admin/utils/time.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import magic_admin 4 | 5 | 6 | def epoch_time_now(): 7 | return int(time.time()) 8 | 9 | 10 | def apply_did_token_nbf_grace_period(timestamp): 11 | return timestamp - magic_admin.did_token_nbf_grace_period_s 12 | -------------------------------------------------------------------------------- /magic_admin/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '1.0.0' 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | appnope==0.1.0 2 | aspy.yaml==1.3.0 3 | attrs==19.3.0 4 | backcall==0.1.0 5 | cfgv==3.1.0 6 | coverage==4.5.1 7 | decorator==4.4.2 8 | identify==1.4.14 9 | importlib-metadata==1.6.0 10 | importlib-resources==1.4.0 11 | ipdb==0.12.3 12 | ipython==7.11.1 13 | ipython-genutils==0.2.0 14 | jedi==0.16.0 15 | more-itertools==8.2.0 16 | nodeenv==1.3.5 17 | packaging==21.3 18 | parso==0.6.2 19 | pexpect==4.8.0 20 | pickleshare==0.7.5 21 | pluggy==0.13.1 22 | pre-commit==1.21.0 23 | pretend==1.0.8 24 | prompt-toolkit==3.0.5 25 | ptyprocess==0.6.0 26 | py==1.8.1 27 | Pygments==2.7.4 28 | pyparsing==2.4.7 29 | pytest==5.4.1 30 | pytest-cov==2.8.1 31 | pytest-mock==3.6.1 32 | PyYAML==5.4.1 33 | six==1.14.0 34 | toml==0.10.0 35 | tox==3.0.0 36 | traitlets==4.3.3 37 | virtualenv==16.7.9 38 | wcwidth==0.1.9 39 | zipp==3.1.0 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests >= 2.22.0, <3 2 | web3 >= 4.8.1, <6 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE.txt 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | from os.path import join 3 | 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | 7 | with open('README.md') as fh: 8 | long_description = fh.read() 9 | 10 | 11 | def read_version(): 12 | version_contents = {} 13 | with open(join(dirname(__file__), 'magic_admin', 'version.py')) as fh: 14 | exec(fh.read(), version_contents) 15 | 16 | return version_contents['VERSION'] 17 | 18 | 19 | def load_readme(): 20 | with open(join(dirname(__file__), 'README.md')) as fh: 21 | long_description = fh.read() 22 | 23 | return long_description 24 | 25 | 26 | def load_requirements(): 27 | with open(join(dirname(__file__), 'requirements.txt')) as fh: 28 | requirements = fh.readlines() 29 | 30 | return requirements 31 | 32 | 33 | setup( 34 | name='magic-admin', 35 | version=read_version(), 36 | description='Magic Python Library', 37 | long_description=load_readme(), 38 | long_description_content_type='text/markdown', 39 | author='Magic', 40 | author_email='support@magic.link', 41 | url='https://magic.link', 42 | license='MIT', 43 | keywords='magic python sdk', 44 | packages=find_packages( 45 | exclude=[ 46 | 'tests', 47 | 'tests.*', 48 | 'testing', 49 | 'testing.*', 50 | 'virtualenv_run', 51 | 'virtualenv_run.*', 52 | ], 53 | ), 54 | zip_safe=False, 55 | install_requires=load_requirements(), 56 | python_requires='>=3.6', 57 | project_urls={ 58 | 'Website': 'https://magic.link', 59 | }, 60 | classifiers=[ 61 | 'Development Status :: 3 - Alpha', 62 | 'Programming Language :: Python', 63 | 'Programming Language :: Python :: 3.6', 64 | 'Intended Audience :: Developers', 65 | 'License :: OSI Approved :: MIT License', 66 | 'Operating System :: OS Independent', 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/testing/__init__.py -------------------------------------------------------------------------------- /testing/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/testing/data/__init__.py -------------------------------------------------------------------------------- /testing/data/did_token.py: -------------------------------------------------------------------------------- 1 | public_address = '0x4B73C58370AEfcEf86A6021afCDe5673511376B2' 2 | 3 | issuer = 'did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2' 4 | 5 | proof = '0xaa50be70729ca705ba7c8d00185c6f2da479d0fcde5311ca4ce5b1ba715c8a721c5' \ 6 | 'f1948434f96ff577d7b2b6ad82d3dd5a2457fe6998b137ed9bc08d36e549c1b' 7 | 8 | claim = { 9 | 'iat': 1586764270, 10 | 'ext': 11173528500, 11 | 'iss': 'did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2', 12 | 'sub': 'NjrA53ScQ8IV80NJnx4t3Shi9-kFfF5qavD2Vr0d1dc=', 13 | 'aud': 'did:magic:731848cc-084e-41ff-bbdf-7f103817ea6b', 14 | 'nbf': 1586764270, 15 | 'tid': 'ebcc880a-ffc9-4375-84ae-154ccd5c746d', 16 | 'add': '0x84d6839268a1af9111fdeccd396f303805dca2bc03450b7eb116e2f5fc8c5a722' 17 | 'd1fb9af233aa73c5c170839ce5ad8141b9b4643380982da4bfbb0b11284988f1b', 18 | } 19 | 20 | future_did_token = 'WyIweGFhNTBiZTcwNzI5Y2E3MDViYTdjOGQwMDE4NWM2ZjJkYTQ3OWQwZm' \ 21 | 'NkZTUzMTFjYTRjZTViMWJhNzE1YzhhNzIxYzVmMTk0ODQzNGY5NmZmNTc3ZDdiMmI2YWQ4MmQ' \ 22 | 'zZGQ1YTI0NTdmZTY5OThiMTM3ZWQ5YmMwOGQzNmU1NDljMWIiLCJ7XCJpYXRcIjoxNTg2NzY0' \ 23 | 'MjcwLFwiZXh0XCI6MTExNzM1Mjg1MDAsXCJpc3NcIjpcImRpZDpldGhyOjB4NEI3M0M1ODM3M' \ 24 | 'EFFZmNFZjg2QTYwMjFhZkNEZTU2NzM1MTEzNzZCMlwiLFwic3ViXCI6XCJOanJBNTNTY1E4SV' \ 25 | 'Y4ME5Kbng0dDNTaGk5LWtGZkY1cWF2RDJWcjBkMWRjPVwiLFwiYXVkXCI6XCJkaWQ6bWFnaWM' \ 26 | '6NzMxODQ4Y2MtMDg0ZS00MWZmLWJiZGYtN2YxMDM4MTdlYTZiXCIsXCJuYmZcIjoxNTg2NzY0' \ 27 | 'MjcwLFwidGlkXCI6XCJlYmNjODgwYS1mZmM5LTQzNzUtODRhZS0xNTRjY2Q1Yzc0NmRcIixcI' \ 28 | 'mFkZFwiOlwiMHg4NGQ2ODM5MjY4YTFhZjkxMTFmZGVjY2QzOTZmMzAzODA1ZGNhMmJjMDM0NT' \ 29 | 'BiN2ViMTE2ZTJmNWZjOGM1YTcyMmQxZmI5YWYyMzNhYTczYzVjMTcwODM5Y2U1YWQ4MTQxYjl' \ 30 | 'iNDY0MzM4MDk4MmRhNGJmYmIwYjExMjg0OTg4ZjFiXCJ9Il0=' 31 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/magic_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | import magic_admin 6 | from magic_admin.error import AuthenticationError 7 | from magic_admin.magic import Magic 8 | from magic_admin.resources.base import ResourceComponent 9 | 10 | 11 | class TestMagic: 12 | 13 | api_secret_key = 'troll_goat' 14 | 15 | @pytest.fixture(autouse=True) 16 | def setup(self): 17 | self.mocked_rc = mock.Mock( 18 | request=mock.Mock( 19 | return_value=mock.Mock( 20 | data={ 21 | 'client_id': '1234', 22 | }, 23 | ), 24 | ), 25 | ) 26 | # self.mocked_rc.request= 27 | with mock.patch('magic_admin.magic.RequestsClient', return_value=self.mocked_rc): 28 | yield 29 | 30 | def test_init_with_secret_key(self): 31 | Magic(api_secret_key=self.api_secret_key) 32 | 33 | assert magic_admin.api_secret_key == self.api_secret_key 34 | 35 | @pytest.mark.parametrize( 36 | 'resource_name', 37 | ResourceComponent._registry.keys(), 38 | ) 39 | def test_init_with_request_client_set_on_resources(self, resource_name): 40 | magic = Magic(api_secret_key=self.api_secret_key) 41 | 42 | assert getattr(magic, resource_name)._request_client 43 | 44 | @pytest.mark.parametrize( 45 | 'resource_name', 46 | ResourceComponent._registry.keys(), 47 | ) 48 | def test_gets_resource(self, resource_name): 49 | magic = Magic(api_secret_key=self.api_secret_key) 50 | 51 | assert getattr(magic, resource_name) 52 | 53 | def test_raises_attr_error(self): 54 | with pytest.raises(AttributeError): 55 | Magic(api_secret_key=self.api_secret_key).troll_goat 56 | 57 | def test_raises_authentication_error_if_secret_key_missing(self): 58 | with pytest.raises(AuthenticationError): 59 | Magic() 60 | -------------------------------------------------------------------------------- /tests/integration/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/integration/resources/__init__.py -------------------------------------------------------------------------------- /tests/integration/resources/token_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pretend import stub 4 | 5 | from magic_admin.resources.token import Token 6 | from testing.data.did_token import claim 7 | from testing.data.did_token import future_did_token 8 | from testing.data.did_token import issuer 9 | from testing.data.did_token import proof 10 | from testing.data.did_token import public_address 11 | 12 | 13 | class TestToken: 14 | 15 | def test_check_required_fields(self): 16 | Token._check_required_fields(claim) 17 | 18 | def test_decode(self): 19 | assert Token.decode(future_did_token) == (proof, claim) 20 | 21 | def test_get_issuer(self): 22 | assert Token.get_issuer(future_did_token) == issuer 23 | 24 | def test_get_public_address(self): 25 | assert Token.get_public_address(future_did_token) == public_address 26 | 27 | def test_validate(self): 28 | with mock.patch( 29 | 'magic_admin.resources.token.magic_admin', 30 | new=stub(client_id='did:magic:731848cc-084e-41ff-bbdf-7f103817ea6b'), 31 | ): 32 | Token.validate(future_did_token) 33 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/config_test.py: -------------------------------------------------------------------------------- 1 | from magic_admin.config import base_url 2 | 3 | 4 | def test_base_url(): 5 | assert base_url == 'https://api.magic.link' 6 | -------------------------------------------------------------------------------- /tests/unit/error_test.py: -------------------------------------------------------------------------------- 1 | from magic_admin.error import APIConnectionError 2 | from magic_admin.error import APIError 3 | from magic_admin.error import AuthenticationError 4 | from magic_admin.error import BadRequestError 5 | from magic_admin.error import DIDTokenInvalid 6 | from magic_admin.error import ForbiddenError 7 | from magic_admin.error import MagicError 8 | from magic_admin.error import RateLimitingError 9 | from magic_admin.error import RequestError 10 | 11 | 12 | class MagicErrorBase: 13 | 14 | error_class = None 15 | 16 | message = 'Magic is amazing' 17 | 18 | def test_str(self): 19 | assert str(self.error_class(self.message)) == self.message 20 | 21 | def test_str_with_empty_message(self): 22 | assert str(self.error_class()) == '' 23 | 24 | def test_repr(self): 25 | assert repr(self.error_class(self.message)) == '{}(message=\'Magic is ' \ 26 | 'amazing\')'.format(self.error_class.__name__) 27 | 28 | def test_to_dict(self): 29 | assert self.error_class(self.message).to_dict() == {'message': str(self.message)} 30 | 31 | 32 | class TestMagicError(MagicErrorBase): 33 | 34 | error_class = MagicError 35 | 36 | 37 | class TestDIDTokenInvalid(MagicErrorBase): 38 | 39 | error_class = DIDTokenInvalid 40 | 41 | 42 | class TestAPIConnectionError(MagicErrorBase): 43 | 44 | error_class = APIConnectionError 45 | 46 | 47 | class RequestErrorBase: 48 | 49 | error_class = None 50 | 51 | message = 'Magic is amazing' 52 | http_status = 'success' 53 | http_code = 200 54 | http_resp_data = {'magic': 'link'} 55 | http_message = 'Troll goat is cute' 56 | http_error_code = 'TROLL_GOAT_IS_CUTE' 57 | http_request_params = 'a=b&b=c' 58 | http_request_data = {'magic': 'link'} 59 | http_method = 'post' 60 | 61 | def test_str(self): 62 | assert str(self.error_class(self.message)) == self.message 63 | 64 | def test_str_with_empty_message(self): 65 | assert str(self.error_class()) == '' 66 | 67 | def test_repr(self): 68 | assert repr( 69 | self.error_class( 70 | self.message, 71 | http_error_code=self.http_error_code, 72 | http_code=self.http_code, 73 | ), 74 | ) == '{}(message=\'Magic is amazing\', http_error_code={}, http_code={}).'.format( 75 | self.error_class.__name__, 76 | self.http_error_code, 77 | self.http_code, 78 | ) 79 | 80 | def test_to_dict(self): 81 | assert self.error_class( 82 | self.message, 83 | http_error_code=self.http_error_code, 84 | http_code=self.http_code, 85 | http_status=self.http_status, 86 | http_resp_data=self.http_resp_data, 87 | http_message=self.http_message, 88 | http_request_params=self.http_request_params, 89 | http_request_data=self.http_request_data, 90 | http_method=self.http_method, 91 | ).to_dict() == { 92 | 'message': self.message, 93 | 'http_error_code': self.http_error_code, 94 | 'http_code': self.http_code, 95 | 'http_status': self.http_status, 96 | 'http_resp_data': self.http_resp_data, 97 | 'http_message': self.http_message, 98 | 'http_request_params': self.http_request_params, 99 | 'http_request_data': self.http_request_data, 100 | 'http_method': self.http_method, 101 | } 102 | 103 | 104 | class TestRequestError(RequestErrorBase): 105 | 106 | error_class = RequestError 107 | 108 | 109 | class TestRateLimitingError(RequestErrorBase): 110 | 111 | error_class = RateLimitingError 112 | 113 | 114 | class TestBadRequestError(RequestErrorBase): 115 | 116 | error_class = BadRequestError 117 | 118 | 119 | class TestAuthenticationError(RequestErrorBase): 120 | 121 | error_class = AuthenticationError 122 | 123 | 124 | class TestForbiddenError(RequestErrorBase): 125 | 126 | error_class = ForbiddenError 127 | 128 | 129 | class TestAPIError(RequestErrorBase): 130 | 131 | error_class = APIError 132 | -------------------------------------------------------------------------------- /tests/unit/http_client_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import namedtuple 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | import magic_admin 8 | from magic_admin import version 9 | from magic_admin.config import base_url 10 | from magic_admin.error import APIConnectionError 11 | from magic_admin.error import APIError 12 | from magic_admin.error import AuthenticationError 13 | from magic_admin.error import BadRequestError 14 | from magic_admin.error import ForbiddenError 15 | from magic_admin.error import RateLimitingError 16 | from magic_admin.http_client import RequestsClient 17 | from magic_admin.response import MagicResponse 18 | 19 | 20 | class TestRequestsClient: 21 | 22 | retries = 1 23 | timeout = 2 24 | backoff_factor = 3 25 | 26 | def test_init(self): 27 | with mock.patch( 28 | 'magic_admin.http_client.RequestsClient._setup_request_session', 29 | ) as mock_setup_request_session: 30 | rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 31 | 32 | assert rc._retries == self.retries 33 | assert rc._timeout == self.timeout 34 | assert rc._backoff_factor == self.backoff_factor 35 | 36 | mock_setup_request_session.assert_called_once_with() 37 | 38 | def test_get_platform_info(self): 39 | platform_name = 'troll_goat' 40 | py_version = '9.0.0.0' 41 | error_msg = 'error_msg' 42 | 43 | platform = mock.Mock( 44 | platform=mock.Mock(return_value=platform_name), 45 | python_version=mock.Mock(return_value=py_version), 46 | uname=mock.Mock(side_effect=Exception(error_msg)), 47 | ) 48 | 49 | with mock.patch( 50 | 'magic_admin.http_client.platform', 51 | platform, 52 | ): 53 | assert RequestsClient._get_platform_info() == { 54 | 'platform': platform_name, 55 | 'language_version': py_version, 56 | 'uname': '<{}>'.format(error_msg), 57 | } 58 | 59 | platform.platform.assert_called_once_with() 60 | platform.python_version.assert_called_once_with() 61 | platform.uname.assert_called_once_with() 62 | 63 | def test_setup_request_session(self): 64 | with mock.patch( 65 | 'magic_admin.http_client.Session', 66 | ) as mock_session, mock.patch( 67 | 'magic_admin.http_client.HTTPAdapter', 68 | ) as mock_http_adapter, mock.patch( 69 | 'magic_admin.http_client.Retry', 70 | ) as mock_retry: 71 | RequestsClient(self.retries, self.timeout, self.backoff_factor) 72 | 73 | mock_retry.assert_called_once_with( 74 | total=self.retries, 75 | backoff_factor=self.backoff_factor, 76 | ) 77 | mock_http_adapter.assert_called_once_with( 78 | max_retries=mock_retry.return_value, 79 | ) 80 | mock_session.return_value.mount.assert_called_once_with( 81 | base_url, 82 | mock_http_adapter.return_value, 83 | ) 84 | mock_session.assert_called_once_with() 85 | 86 | def test_get_request_headers(self): 87 | rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 88 | platform_info = {'troll': 'goat'} 89 | magic_admin.api_secret_key = 'magic_secret_key' 90 | 91 | with mock.patch.object( 92 | rc, 93 | '_get_platform_info', 94 | return_value=platform_info, 95 | ) as mock_get_platform_info: 96 | assert rc._get_request_headers() == { 97 | 'X-Magic-Secret-Key': magic_admin.api_secret_key, 98 | 'User-Agent': json.dumps({ 99 | 'language': 'python', 100 | 'sdk_version': version.VERSION, 101 | 'publisher': 'magic', 102 | 'http_lib': rc.__class__.__name__, 103 | **platform_info, 104 | }), 105 | } 106 | 107 | mock_get_platform_info.assert_called_once_with() 108 | 109 | def test_get_request_headers_raises_error(self): 110 | rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 111 | magic_admin.api_secret_key = None 112 | 113 | with pytest.raises(AuthenticationError): 114 | rc._get_request_headers() 115 | 116 | def test_handle_request_error(self): 117 | rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 118 | exception = Exception('troll_goat') 119 | 120 | with pytest.raises(APIConnectionError) as e: 121 | rc._handle_request_error(exception) 122 | 123 | assert str(e.value) == ( 124 | 'Unexpected error thrown while communicating to Magic. ' 125 | 'Please reach out to support@magic.link if the problem continues. ' 126 | 'Error message: {error_class} was raised - {error_message}'.format( 127 | error_class=exception.__class__.__name__, 128 | error_message=str(exception) or 'no error message.', 129 | ) 130 | ) 131 | 132 | 133 | class TestRequestClientRequest: 134 | 135 | retries = 1 136 | timeout = 2 137 | backoff_factor = 3 138 | 139 | mock_tuple = namedtuple( 140 | 'mock_tuple', 141 | [ 142 | 'get_request_headers', 143 | 'handle_request_error', 144 | 'parse_and_convert_to_api_response', 145 | ], 146 | ) 147 | 148 | @pytest.fixture(autouse=True) 149 | def setup(self): 150 | self.some_headers = {'troll': 'goat'} 151 | self.method = 'post' 152 | self.url = '/path' 153 | self.params = 'params' 154 | self.data = 'data' 155 | 156 | self.rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 157 | self.rc.http = mock.Mock() 158 | 159 | @pytest.fixture 160 | def mock_funcs(self): 161 | with mock.patch.object( 162 | self.rc, 163 | '_get_request_headers', 164 | return_value=self.some_headers, 165 | ) as mock_get_request_headers, mock.patch.object( 166 | self.rc, 167 | '_handle_request_error', 168 | ) as mock_handle_request_error, mock.patch.object( 169 | self.rc, 170 | '_parse_and_convert_to_api_response', 171 | ) as mock_parse_and_convert_to_api_response: 172 | yield self.mock_tuple( 173 | mock_get_request_headers, 174 | mock_handle_request_error, 175 | mock_parse_and_convert_to_api_response, 176 | ) 177 | 178 | def test_request_no_exception_and_returns_api_response(self, mock_funcs): 179 | assert self.rc.request( 180 | self.method, 181 | self.url, 182 | params=self.params, 183 | data=self.data, 184 | ) == mock_funcs.parse_and_convert_to_api_response.return_value 185 | 186 | mock_funcs.get_request_headers.assert_called_once_with() 187 | self.rc.http.request.assert_called_once_with( 188 | self.method, 189 | self.url, 190 | params=self.params, 191 | json=self.data, 192 | headers=self.some_headers, 193 | timeout=self.timeout, 194 | ) 195 | mock_funcs.handle_request_error.assert_not_called() 196 | mock_funcs.parse_and_convert_to_api_response.assert_called_once_with( 197 | self.rc.http.request.return_value, 198 | self.params, 199 | self.data, 200 | ) 201 | 202 | def test_request_exceptions_and_handles_error(self, mock_funcs): 203 | exception = Exception() 204 | self.rc.http.request = mock.Mock(side_effect=exception) 205 | 206 | assert self.rc.request( 207 | self.method, 208 | self.url, 209 | params=self.params, 210 | data=self.data, 211 | ) == mock_funcs.handle_request_error.return_value 212 | 213 | mock_funcs.get_request_headers.assert_called_once_with() 214 | self.rc.http.request.assert_called_once_with( 215 | self.method, 216 | self.url, 217 | params=self.params, 218 | json=self.data, 219 | headers=self.some_headers, 220 | timeout=self.timeout, 221 | ) 222 | mock_funcs.handle_request_error.assert_called_once_with(exception) 223 | mock_funcs.parse_and_convert_to_api_response.assert_not_called() 224 | 225 | 226 | class TestParseAndConvertToAPIResponse: 227 | 228 | retries = 1 229 | timeout = 2 230 | backoff_factor = 3 231 | 232 | @pytest.fixture(autouse=True) 233 | def setup(self): 234 | self.params = 'params' 235 | self.request_data = 'request_data' 236 | self.data = { 237 | 'data': 'troll_goat', 238 | 'status': 'failed', 239 | 'message': 'troll_goat_is_cute', 240 | 'error_code': 'some_error', 241 | } 242 | 243 | self.rc = RequestsClient(self.retries, self.timeout, self.backoff_factor) 244 | self.resp = mock.Mock(json=mock.Mock(return_value=self.data)) 245 | 246 | def test_ok_response(self): 247 | self.resp.status_code = 200 248 | 249 | parsed_resp = self.rc._parse_and_convert_to_api_response( 250 | self.resp, 251 | self.params, 252 | self.request_data, 253 | ) 254 | 255 | assert isinstance(parsed_resp, MagicResponse) 256 | assert parsed_resp.content == self.resp.content 257 | assert parsed_resp.status_code == self.resp.status_code 258 | assert parsed_resp.data == self.data['data'] 259 | 260 | @pytest.mark.parametrize( 261 | 'status_code,error_class', 262 | [ 263 | (400, BadRequestError), 264 | (401, AuthenticationError), 265 | (403, ForbiddenError), 266 | (429, RateLimitingError), 267 | # Generic API Error if we did not specify handling it. 268 | (499, APIError), 269 | ], 270 | ) 271 | def test_client_error_response(self, status_code, error_class): 272 | self.resp.status_code = status_code 273 | 274 | with pytest.raises(error_class) as e: 275 | self.rc._parse_and_convert_to_api_response( 276 | self.resp, 277 | self.params, 278 | self.request_data, 279 | ) 280 | 281 | assert e.value.to_dict() == { 282 | 'http_status': self.data['status'], 283 | 'http_code': self.resp.status_code, 284 | 'http_resp_data': self.data['data'], 285 | 'http_message': self.data['message'], 286 | 'http_error_code': self.data['error_code'], 287 | 'http_request_params': self.params, 288 | 'http_request_data': self.request_data, 289 | 'http_method': self.resp.request.method, 290 | 'message': mock.ANY, 291 | } 292 | -------------------------------------------------------------------------------- /tests/unit/magic_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | import magic_admin 6 | from magic_admin.error import AuthenticationError 7 | from magic_admin.magic import BACKOFF_FACTOR 8 | from magic_admin.magic import Magic 9 | from magic_admin.magic import RETRIES 10 | from magic_admin.magic import TIMEOUT 11 | 12 | 13 | class TestMagic: 14 | 15 | api_secret_key = 'troll_goat' 16 | 17 | @pytest.fixture(autouse=True) 18 | def setup(self): 19 | self.mocked_resource_component = mock.Mock() 20 | self.mocked_request_client = mock.Mock( 21 | request=mock.Mock( 22 | return_value=mock.Mock( 23 | data={ 24 | 'client_id': '1234', 25 | }, 26 | ), 27 | ), 28 | ) 29 | with mock.patch( 30 | 'magic_admin.magic.ResourceComponent', 31 | return_value=self.mocked_resource_component, 32 | ), mock.patch( 33 | 'magic_admin.magic.RequestsClient', 34 | return_value=self.mocked_request_client, 35 | ): 36 | yield 37 | 38 | @pytest.fixture(autouse=True) 39 | def teardown(self): 40 | yield 41 | magic_admin.api_secret_key = None 42 | 43 | def test_init(self): 44 | with mock.patch( 45 | 'magic_admin.magic.Magic._set_api_secret_key', 46 | ) as mock_set_api_secret_key: 47 | Magic(api_secret_key=self.api_secret_key) 48 | 49 | self.mocked_resource_component.setup_request_client.assert_called_once_with( 50 | RETRIES, 51 | TIMEOUT, 52 | BACKOFF_FACTOR, 53 | ) 54 | mock_set_api_secret_key.assert_called_once_with(self.api_secret_key) 55 | 56 | def test_retrieves_secret_key_from_env_variable(self): 57 | assert magic_admin.api_secret_key is None 58 | 59 | with mock.patch( 60 | 'os.environ.get', 61 | return_value=self.api_secret_key, 62 | ) as mock_env_get: 63 | Magic() 64 | 65 | assert magic_admin.api_secret_key == self.api_secret_key 66 | mock_env_get.assert_called_once_with('MAGIC_API_SECRET_KEY') 67 | 68 | def test_retrieves_secret_key_from_the_passed_in_value(self): 69 | assert magic_admin.api_secret_key is None 70 | 71 | Magic(api_secret_key=self.api_secret_key) 72 | 73 | assert magic_admin.api_secret_key == self.api_secret_key 74 | 75 | def test_raises_authentication_error_if_secret_key_is_missing(self): 76 | with pytest.raises(AuthenticationError): 77 | Magic() 78 | -------------------------------------------------------------------------------- /tests/unit/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/unit/resources/__init__.py -------------------------------------------------------------------------------- /tests/unit/resources/base_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from magic_admin.resources.base import ResourceComponent 6 | 7 | 8 | class TestResourceComponent: 9 | 10 | retries = 1 11 | timeout = 2 12 | backoff_factor = 3 13 | 14 | method = 'get' 15 | url_path = '/troll/goat' 16 | params = 'params' 17 | data = 'data' 18 | 19 | @pytest.fixture(autouse=True) 20 | def setup(self): 21 | self.rc = ResourceComponent() 22 | 23 | def test_setup_request_client(self): 24 | with mock.patch( 25 | 'magic_admin.resources.base.RequestsClient', 26 | ) as mock_request_client: 27 | self.rc.setup_request_client( 28 | self.retries, 29 | self.timeout, 30 | self.backoff_factor, 31 | ) 32 | 33 | mock_request_client.assert_called_once_with( 34 | self.retries, 35 | self.timeout, 36 | self.backoff_factor, 37 | ) 38 | for resource in self.rc._registry.values(): 39 | assert getattr(resource, '_request_client') == \ 40 | mock_request_client.return_value 41 | 42 | def test_construct_url(self): 43 | assert self.rc._construct_url(self.url_path) == '{}{}'.format( 44 | self.rc._base_url, 45 | self.url_path, 46 | ) 47 | 48 | def test_request(self): 49 | self.rc._request_client = mock.Mock() 50 | 51 | with mock.patch.object( 52 | self.rc, 53 | '_construct_url', 54 | ) as mock_construct_url: 55 | assert self.rc.request( 56 | self.method, 57 | self.url_path, 58 | params=self.params, 59 | data=self.data, 60 | ) == self.rc._request_client.request.return_value 61 | 62 | mock_construct_url.assert_called_once_with(self.url_path) 63 | -------------------------------------------------------------------------------- /tests/unit/resources/token_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import namedtuple 3 | from unittest import mock 4 | 5 | import pytest 6 | from pretend import stub 7 | 8 | from magic_admin.error import DIDTokenExpired 9 | from magic_admin.error import DIDTokenInvalid 10 | from magic_admin.error import DIDTokenMalformed 11 | from magic_admin.resources.token import Token 12 | 13 | 14 | class TestToken: 15 | 16 | did_token = 'magic_token' 17 | public_address = 'magic_address' 18 | issuer = 'did:ethr:{}'.format(public_address) 19 | 20 | @staticmethod 21 | def _generate_claim(fields=None): 22 | return {field: mock.ANY for field in fields or Token.required_fields} 23 | 24 | def test_required_fields(self): 25 | assert Token.required_fields.difference( 26 | {'nbf', 'sub', 'iss', 'ext', 'aud', 'tid', 'iat'}, 27 | ) == frozenset() 28 | 29 | def test_check_required_fields_raises_error(self): 30 | with pytest.raises(DIDTokenMalformed) as e: 31 | Token._check_required_fields( 32 | self._generate_claim(fields=['nbf', 'sub', 'aud', 'tid', 'iat']), 33 | ) 34 | 35 | assert str(e.value) == 'DID token is missing required field(s): ' \ 36 | '[\'ext\', \'iss\']' 37 | 38 | def test_check_required_fields_passes(self): 39 | Token._check_required_fields(self._generate_claim()) 40 | 41 | def test_get_issuer_passes(self): 42 | mocked_claim = {'iss': self.issuer} 43 | 44 | with mock.patch.object( 45 | Token, 46 | 'decode', 47 | return_value=(mock.ANY, mocked_claim), 48 | ) as mock_decode: 49 | assert Token.get_issuer(self.did_token) == self.issuer 50 | 51 | mock_decode.assert_called_once_with(self.did_token) 52 | 53 | def test_get_public_address_passes(self): 54 | with mock.patch( 55 | 'magic_admin.resources.token.parse_public_address_from_issuer', 56 | return_value=self.public_address, 57 | ) as mock_parse_public_address, mock.patch.object( 58 | Token, 59 | 'get_issuer', 60 | ) as mock_get_issuer: 61 | assert Token.get_public_address(self.did_token) == self.public_address 62 | 63 | mock_get_issuer.assert_called_once_with(self.did_token) 64 | mock_parse_public_address.assert_called_once_with(mock_get_issuer.return_value) 65 | 66 | 67 | class TestTokenDecode: 68 | 69 | did_token = 'magic_token' 70 | public_address = 'magic_address' 71 | 72 | mock_funcs = namedtuple('mock_funcs', 'urlsafe_b64decode, json_loads') 73 | 74 | @pytest.fixture 75 | def setup_mocks(self): 76 | with mock.patch( 77 | 'magic_admin.resources.token.base64.urlsafe_b64decode', 78 | ) as mock_urlsafe_b64decode, mock.patch( 79 | 'magic_admin.resources.token.json.loads', 80 | ) as mock_json_loads: 81 | yield self.mock_funcs(mock_urlsafe_b64decode, mock_json_loads) 82 | 83 | def test_decode_raises_error_if_did_token_is_malformed(self, setup_mocks): 84 | setup_mocks.urlsafe_b64decode.side_effect = Exception() 85 | 86 | with pytest.raises(DIDTokenMalformed) as e: 87 | Token.decode(self.did_token) 88 | 89 | setup_mocks.urlsafe_b64decode.assert_called_once_with(self.did_token) 90 | assert str(e.value) == 'DID token is malformed. It has to be a based64 ' \ 91 | 'encoded JSON serialized string. Exception ().' 92 | 93 | def test_decode_raises_error_if_did_token_has_missing_parts(self, setup_mocks): 94 | setup_mocks.json_loads.return_value = ('miss one part') 95 | 96 | with pytest.raises(DIDTokenMalformed) as e: 97 | Token.decode(self.did_token) 98 | 99 | setup_mocks.urlsafe_b64decode.assert_called_once_with(self.did_token) 100 | setup_mocks.json_loads.assert_called_once_with( 101 | setup_mocks.urlsafe_b64decode.return_value.decode.return_value, 102 | ) 103 | assert str(e.value) == 'DID token is malformed. It has to have two parts ' \ 104 | '[proof, claim].' 105 | 106 | def test_decode_raises_error_if_claim_is_not_json_serializable(self, setup_mocks): 107 | with pytest.raises(DIDTokenMalformed) as e: 108 | setup_mocks.json_loads.side_effect = [ 109 | ('proof_in_str', 'claim_in_str'), # Succeeds the first time. 110 | Exception(), # Fails the second time. 111 | ] 112 | 113 | Token.decode(self.did_token) 114 | 115 | setup_mocks.urlsafe_b64decode.assert_called_once_with(self.did_token) 116 | assert setup_mocks.json_loads.call_args_list == [ 117 | mock.call(setup_mocks.urlsafe_b64decode.return_value.decode.return_value), 118 | mock.call('claim_in_str'), 119 | ] 120 | assert str(e.value) == 'DID token is malformed. Given claim should be ' \ 121 | 'a JSON serialized string. Exception ().' 122 | 123 | def test_decode_passes(self, setup_mocks): 124 | setup_mocks.json_loads.side_effect = [ 125 | ('proof_in_str', 'claim_in_str'), 126 | 'claim', 127 | ] 128 | 129 | with mock.patch.object( 130 | Token, 131 | '_check_required_fields', 132 | ) as mock_check_required_fields: 133 | assert Token.decode(self.did_token) == ('proof_in_str', 'claim') 134 | 135 | setup_mocks.urlsafe_b64decode.assert_called_once_with(self.did_token) 136 | mock_check_required_fields.assert_called_once_with('claim') 137 | assert setup_mocks.json_loads.call_args_list == [ 138 | mock.call(setup_mocks.urlsafe_b64decode.return_value.decode.return_value), 139 | mock.call('claim_in_str'), 140 | ] 141 | 142 | 143 | class TestTokenValidate: 144 | 145 | did_token = 'magic_token' 146 | public_address = 'magic_address' 147 | 148 | mock_funcs = namedtuple( 149 | 'mock_funcs', 150 | [ 151 | 'proof', 152 | 'claim', 153 | 'decode', 154 | 'recoverHash', 155 | 'defunct_hash_message', 156 | 'get_public_address', 157 | 'epoch_time_now', 158 | 'apply_did_token_nbf_grace_period', 159 | ], 160 | ) 161 | 162 | @pytest.fixture 163 | def setup_mocks(self): 164 | proof = 'proof' 165 | claim = { 166 | 'ext': 8084, 167 | 'nbf': 6666, 168 | 'aud': '1234', 169 | } 170 | 171 | with mock.patch.object( 172 | Token, 173 | 'decode', 174 | return_value=(proof, claim), 175 | ) as decode, mock.patch( 176 | 'magic_admin.resources.token.w3.eth.account.recoverHash', 177 | return_value=self.public_address, 178 | ) as recoverHash, mock.patch( 179 | 'magic_admin.resources.token.defunct_hash_message', 180 | ) as defunct_hash_message, mock.patch.object( 181 | Token, 182 | 'get_public_address', 183 | return_value=self.public_address, 184 | ) as get_public_address, mock.patch( 185 | 'magic_admin.resources.token.epoch_time_now', 186 | return_value=claim['ext'] - 1, 187 | ) as epoch_time_now, mock.patch( 188 | 'magic_admin.resources.token.apply_did_token_nbf_grace_period', 189 | return_value=claim['nbf'], 190 | ) as apply_did_token_nbf_grace_period, mock.patch( 191 | 'magic_admin.resources.token.magic_admin', 192 | new=stub(client_id='1234'), 193 | ): 194 | yield self.mock_funcs( 195 | proof, 196 | claim, 197 | decode, 198 | recoverHash, 199 | defunct_hash_message, 200 | get_public_address, 201 | epoch_time_now, 202 | apply_did_token_nbf_grace_period, 203 | ) 204 | 205 | def _assert_validate_funcs_called( 206 | self, 207 | setup_mocks, 208 | is_time_func_called=False, 209 | is_grace_period_func_called=False, 210 | ): 211 | setup_mocks.decode.assert_called_once_with(self.did_token) 212 | setup_mocks.defunct_hash_message.assert_called_once_with( 213 | text=json.dumps(setup_mocks.claim, separators=(',', ':')), 214 | ) 215 | setup_mocks.recoverHash.assert_called_once_with( 216 | setup_mocks.defunct_hash_message.return_value, 217 | signature=setup_mocks.proof, 218 | ) 219 | setup_mocks.get_public_address.assert_called_once_with( 220 | self.did_token, 221 | ) 222 | 223 | if is_time_func_called: 224 | setup_mocks.epoch_time_now.assert_called_once_with() 225 | else: 226 | setup_mocks.epoch_time_now.assert_not_called() 227 | 228 | if is_grace_period_func_called: 229 | setup_mocks.apply_did_token_nbf_grace_period.assert_called_once_with( 230 | setup_mocks.claim['nbf'], 231 | ) 232 | else: 233 | setup_mocks.apply_did_token_nbf_grace_period.assert_not_called() 234 | 235 | def test_validate_raises_error_if_signature_mismatch(self, setup_mocks): 236 | setup_mocks.get_public_address.return_value = 'random_public_address' 237 | 238 | with pytest.raises(DIDTokenInvalid) as e: 239 | Token.validate(self.did_token) 240 | 241 | self._assert_validate_funcs_called(setup_mocks) 242 | assert str(e.value) == 'Signature mismatch between "proof" and "claim". ' \ 243 | 'Please generate a new token with an intended issuer.' 244 | 245 | def test_validate_raises_error_if_did_token_expires(self, setup_mocks): 246 | setup_mocks.epoch_time_now.return_value = \ 247 | setup_mocks.claim['ext'] + 1 248 | 249 | with pytest.raises(DIDTokenExpired) as e: 250 | Token.validate(self.did_token) 251 | 252 | self._assert_validate_funcs_called( 253 | setup_mocks, 254 | is_time_func_called=True, 255 | ) 256 | assert str(e.value) == 'Given DID token has expired. Please generate a ' \ 257 | 'new one.' 258 | 259 | def test_validate_raises_error_if_did_token_has_no_expiration(self, setup_mocks): 260 | setup_mocks.claim['ext'] = None 261 | 262 | with pytest.raises(DIDTokenInvalid) as e: 263 | Token.validate(self.did_token) 264 | 265 | assert str(e.value) == 'Please check the "ext" field and regenerate a new' \ 266 | ' token with a suitable value.' 267 | 268 | def test_validate_raises_error_if_did_token_used_before_nbf(self, setup_mocks): 269 | setup_mocks.epoch_time_now.return_value = \ 270 | setup_mocks.claim['nbf'] - 1 271 | 272 | with pytest.raises(DIDTokenInvalid) as e: 273 | Token.validate(self.did_token) 274 | 275 | self._assert_validate_funcs_called( 276 | setup_mocks, 277 | is_time_func_called=True, 278 | is_grace_period_func_called=True, 279 | ) 280 | assert str(e.value) == 'Given DID token cannot be used at this time. ' \ 281 | 'Please check the "nbf" field and regenerate a new token with a ' \ 282 | 'suitable value.' 283 | 284 | def test_validate_passes(self, setup_mocks): 285 | Token.validate(self.did_token) 286 | 287 | self._assert_validate_funcs_called( 288 | setup_mocks, 289 | is_time_func_called=True, 290 | is_grace_period_func_called=True, 291 | ) 292 | -------------------------------------------------------------------------------- /tests/unit/resources/user_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import sentinel 3 | 4 | import pytest 5 | from pretend import stub 6 | 7 | from magic_admin.resources.user import User 8 | from magic_admin.resources.wallet import WalletType 9 | from testing.data.did_token import future_did_token 10 | from testing.data.did_token import public_address 11 | 12 | 13 | class TestUser: 14 | 15 | metadata_with_wallets = stub( 16 | data=stub( 17 | email=sentinel.email, 18 | issuer=sentinel.issuer, 19 | public_address=sentinel.public_address, 20 | wallets=[ 21 | stub( 22 | network=sentinel.network, 23 | wallet_type=WalletType.ETH.value, 24 | public_address=sentinel.public_address_1, 25 | ), 26 | stub( 27 | network=sentinel.network, 28 | wallet_type=WalletType.ETH.value, 29 | public_address=sentinel.public_address_2, 30 | ), 31 | stub( 32 | network=sentinel.network, 33 | wallet_type=WalletType.ETH.value, 34 | public_address=sentinel.public_address_3, 35 | ), 36 | ], 37 | ), 38 | error_code=sentinel.error_code, 39 | message=sentinel.message, 40 | status=sentinel.status, 41 | ) 42 | 43 | metadata_no_wallets = stub( 44 | data=stub( 45 | email=sentinel.email, 46 | issuer=sentinel.issuer, 47 | public_address=sentinel.public_address, 48 | ), 49 | error_code=sentinel.error_code, 50 | message=sentinel.message, 51 | status=sentinel.ok, 52 | ) 53 | 54 | @pytest.fixture(autouse=True) 55 | def setup(self): 56 | self.user = User() 57 | self.user.Token = mock.Mock() 58 | 59 | @pytest.fixture 60 | def mock_construct_issuer_with_public_address(self, mocker): 61 | return mocker.patch( 62 | 'magic_admin.resources.user.construct_issuer_with_public_address', 63 | return_value=sentinel.public_address, 64 | ) 65 | 66 | def test_get_metadata_by_issuer(self): 67 | self.user.get_metadata_by_issuer_and_wallet = mock.Mock( 68 | return_value=self.metadata_no_wallets, 69 | ) 70 | 71 | assert self.user.get_metadata_by_issuer( 72 | sentinel.issuer, 73 | ) == self.metadata_no_wallets 74 | 75 | self.user.get_metadata_by_issuer_and_wallet.assert_called_once_with( 76 | sentinel.issuer, 77 | WalletType.NONE, 78 | ) 79 | 80 | def test_get_metadata_by_issuer_and_any_wallet(self): 81 | self.user.request = mock.Mock(return_value=self.metadata_with_wallets) 82 | 83 | assert self.user.get_metadata_by_issuer_and_wallet( 84 | sentinel.issuer, 85 | WalletType.ANY, 86 | ) == self.metadata_with_wallets 87 | 88 | self.user.request.assert_called_once_with( 89 | 'get', 90 | self.user.v1_user_info, 91 | params={ 92 | 'issuer': sentinel.issuer, 93 | 'wallet_type': WalletType.ANY, 94 | }, 95 | ) 96 | 97 | def test_get_metadata_by_token(self): 98 | self.user.get_metadata_by_issuer = mock.Mock(return_value=self.metadata_no_wallets) 99 | 100 | assert self.user.get_metadata_by_token( 101 | future_did_token, 102 | ) == self.user.get_metadata_by_issuer.return_value 103 | 104 | self.user.Token.get_issuer.assert_called_once_with(future_did_token) 105 | self.user.get_metadata_by_issuer.assert_called_once_with( 106 | self.user.Token.get_issuer.return_value, 107 | ) 108 | 109 | def test_get_metadata_by_token_and_any_wallet(self): 110 | self.user.get_metadata_by_issuer_and_wallet = mock.Mock( 111 | return_value=self.metadata_with_wallets, 112 | ) 113 | 114 | assert self.user.get_metadata_by_token_and_wallet( 115 | future_did_token, 116 | WalletType.ANY, 117 | ) == self.user.get_metadata_by_issuer_and_wallet.return_value 118 | 119 | self.user.Token.get_issuer.assert_called_once_with(future_did_token) 120 | self.user.get_metadata_by_issuer_and_wallet.assert_called_once_with( 121 | self.user.Token.get_issuer.return_value, 122 | WalletType.ANY, 123 | ) 124 | 125 | def test_get_metadata_by_public_address( 126 | self, 127 | mock_construct_issuer_with_public_address, 128 | ): 129 | self.user.get_metadata_by_issuer = mock.Mock(return_value=self.metadata_no_wallets) 130 | 131 | assert self.user.get_metadata_by_public_address( 132 | sentinel.public_address, 133 | ) == self.user.get_metadata_by_issuer.return_value 134 | 135 | mock_construct_issuer_with_public_address.assert_called_once_with( 136 | sentinel.public_address, 137 | ) 138 | self.user.get_metadata_by_issuer.assert_called_once_with( 139 | mock_construct_issuer_with_public_address.return_value, 140 | ) 141 | 142 | def test_get_metadata_by_public_address_and_any_wallet( 143 | self, 144 | mock_construct_issuer_with_public_address, 145 | ): 146 | self.user.get_metadata_by_issuer_and_wallet = mock.Mock( 147 | return_value=self.metadata_with_wallets, 148 | ) 149 | 150 | assert self.user.get_metadata_by_public_address_and_wallet( 151 | sentinel.public_address, 152 | WalletType.ANY, 153 | ) == self.user.get_metadata_by_issuer_and_wallet.return_value 154 | 155 | mock_construct_issuer_with_public_address.assert_called_once_with( 156 | sentinel.public_address, 157 | ) 158 | self.user.get_metadata_by_issuer_and_wallet.assert_called_once_with( 159 | mock_construct_issuer_with_public_address.return_value, 160 | WalletType.ANY, 161 | ) 162 | 163 | def test_logout_by_issuer(self): 164 | self.user.request = mock.Mock() 165 | 166 | assert self.user.logout_by_issuer( 167 | sentinel.issuer, 168 | ) 169 | 170 | self.user.request.assert_called_once_with( 171 | 'post', 172 | self.user.v2_user_logout, 173 | data={ 174 | 'issuer': sentinel.issuer, 175 | }, 176 | ) 177 | 178 | def test_logout_by_public_address( 179 | self, 180 | mock_construct_issuer_with_public_address, 181 | ): 182 | self.user.logout_by_issuer = mock.Mock() 183 | 184 | assert self.user.logout_by_public_address( 185 | public_address, 186 | ) == self.user.logout_by_issuer.return_value 187 | 188 | mock_construct_issuer_with_public_address.assert_called_once_with( 189 | public_address, 190 | ) 191 | self.user.logout_by_issuer.assert_called_once_with( 192 | mock_construct_issuer_with_public_address.return_value, 193 | ) 194 | 195 | def test_logout_by_token(self): 196 | self.user.logout_by_issuer = mock.Mock() 197 | 198 | assert self.user.logout_by_token( 199 | future_did_token, 200 | ) == self.user.logout_by_issuer.return_value 201 | 202 | self.user.Token.get_issuer.assert_called_once_with(future_did_token) 203 | self.user.logout_by_issuer.assert_called_once_with( 204 | self.user.Token.get_issuer.return_value, 205 | ) 206 | -------------------------------------------------------------------------------- /tests/unit/response_test.py: -------------------------------------------------------------------------------- 1 | from magic_admin.response import MagicResponse 2 | 3 | 4 | class TestMagicResponse: 5 | 6 | content = 'troll_goat' 7 | status_code = 200 8 | resp_data = {'data': 'another_troll_goat'} 9 | 10 | def test_response(self): 11 | resp = MagicResponse( 12 | content=self.content, 13 | status_code=self.status_code, 14 | resp_data=self.resp_data, 15 | ) 16 | 17 | assert resp.content == self.content 18 | assert resp.status_code == self.status_code 19 | assert resp.data == self.resp_data['data'] 20 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magiclabs/magic-admin-python/2691a1a765559e3fc8c9f18a9d7642b44d318605/tests/unit/utils/__init__.py -------------------------------------------------------------------------------- /tests/unit/utils/did_token_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from magic_admin.error import DIDTokenMalformed 4 | from magic_admin.utils.did_token import construct_issuer_with_public_address 5 | from magic_admin.utils.did_token import parse_public_address_from_issuer 6 | from testing.data.did_token import issuer 7 | from testing.data.did_token import public_address 8 | 9 | 10 | class TestDIDToken: 11 | 12 | malformed_issuer = 'troll_goat' 13 | 14 | def test_parse_public_address_from_issuer(self): 15 | assert parse_public_address_from_issuer(issuer) == public_address 16 | 17 | def test_parse_public_address_from_issuer_raises_error(self): 18 | with pytest.raises(DIDTokenMalformed) as e: 19 | parse_public_address_from_issuer(self.malformed_issuer) 20 | 21 | assert str(e.value) == \ 22 | 'Given issuer ({}) is malformed. Please make sure it follows the ' \ 23 | '`did:method-name:method-specific-id` format.'.format(self.malformed_issuer) 24 | 25 | def test_construct_issuer_with_public_address(self): 26 | assert issuer == construct_issuer_with_public_address(public_address) 27 | -------------------------------------------------------------------------------- /tests/unit/utils/http_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from magic_admin.utils.http import null_safe 4 | from magic_admin.utils.http import parse_authorization_header_value 5 | 6 | 7 | class TestNullSafe: 8 | 9 | @pytest.mark.parametrize('value', [None, 'null', 'none', 'None', '']) 10 | def test_returns_none(self, value): 11 | assert null_safe(value) is None 12 | 13 | def test_returns_value(self): 14 | value = 'troll_goat' 15 | 16 | assert null_safe(value) == value 17 | 18 | 19 | class TestParseAuthHeaderValue: 20 | 21 | malformed = 'wrong_format' 22 | expected = 'Bearer troll_goat' 23 | 24 | def test_returns_none_if_not_in_bearer_format(self): 25 | assert parse_authorization_header_value(self.malformed) is None 26 | 27 | def test_returns_value(self): 28 | assert parse_authorization_header_value(self.expected) == 'troll_goat' 29 | -------------------------------------------------------------------------------- /tests/unit/utils/time_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from magic_admin import did_token_nbf_grace_period_s 4 | from magic_admin.utils.time import apply_did_token_nbf_grace_period 5 | from magic_admin.utils.time import epoch_time_now 6 | 7 | 8 | class TestTimeUtils: 9 | 10 | def test_epoch_time_now(self): 11 | with mock.patch('magic_admin.utils.time.time') as mock_time: 12 | mock_time.time.return_value = 8084 13 | 14 | assert epoch_time_now() == 8084 15 | 16 | mock_time.time.assert_called_once_with() 17 | 18 | def test_apply_did_token_nbf_grace_period(self): 19 | timestamp = 8084 20 | assert apply_did_token_nbf_grace_period( 21 | timestamp, 22 | ) == timestamp - did_token_nbf_grace_period_s 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | skipsdist=True 4 | 5 | [testenv] 6 | passenv = HOME SSH_AUTH_SOCK USER 7 | envdir = .virtualenv_run_test 8 | commands = 9 | pip install -r requirements.txt 10 | pip install -r requirements-dev.txt 11 | coverage erase 12 | pytest -rxs -p no:warnings --durations=10 --cov=magic_admin --cov-fail-under=0 --cov-report=term-missing tests/ 13 | pre-commit install -f --install-hooks 14 | pre-commit run --all-files 15 | 16 | [flake8] 17 | filename = *.py 18 | max-line-length = 100 19 | 20 | [pep8] 21 | ignore = E265,E309,E501 22 | --------------------------------------------------------------------------------