diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 62% rename from ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug_report.md index 5de83b2cc..2970d494f 100644 --- a/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,21 @@ +--- +name: Bug report +about: Bug reports related to any component in this repo +title: '' +labels: '' +assignees: '' + +--- + ### [READ] Step 1: Are you in the right place? - * For issues or feature requests related to __the code in this repository__ - file a Github issue. - * If this is a __feature request__ make sure the issue title starts with "FR:". + * For issues related to __the code in this repository__ file a GitHub issue. + * If the issue pertains to __Cloud Firestore__, report directly in the + [Python Firestore](https://github.com/googleapis/python-firestore) GitHub repo. Firestore + bugs reported in this repo will be closed with a reference to the Python Firestore + project. * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/) - with the firebase tag. + with the `firebase` tag. * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk) google group. * For help troubleshooting your application that does not fall under one @@ -15,8 +26,9 @@ * Operating System version: _____ * Firebase SDK version: _____ - * Library version: _____ * Firebase Product: _____ (auth, database, storage, etc) + * Python version: _____ + * Pip version: _____ ### [REQUIRED] Step 3: Describe the problem diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..7729d13a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FR]" +labels: 'type: feature request' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context, code samples or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..6a7695c06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg index e8cc3e2a2..5a52805c9 100644 Binary files a/.github/resources/integ-service-account.json.gpg and b/.github/resources/integ-service-account.json.gpg differ diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh index c962d8807..38fe49a88 100755 --- a/.github/scripts/publish_preflight_check.sh +++ b/.github/scripts/publish_preflight_check.sh @@ -71,7 +71,7 @@ if [[ ! "${RELEASE_VERSION}" =~ ^([0-9]*)\.([0-9]*)\.([0-9]*)$ ]]; then fi echo_info "Extracted release version: ${RELEASE_VERSION}" -echo "::set-output name=version::v${RELEASE_VERSION}" +echo "version=v${RELEASE_VERSION}" >> $GITHUB_OUTPUT echo_info "" @@ -159,8 +159,8 @@ echo_info "Generating changelog" echo_info "--------------------------------------------" echo_info "" -echo_info "---< git fetch origin master --prune --unshallow >---" -git fetch origin master --prune --unshallow +echo_info "---< git fetch origin main --prune --unshallow >---" +git fetch origin main --prune --unshallow echo "" echo_info "Generating changelog from history..." @@ -169,12 +169,13 @@ readonly CHANGELOG=`${CURRENT_DIR}/generate_changelog.sh` echo "$CHANGELOG" # Parse and preformat the text to handle multi-line output. -# See https://github.community/t5/GitHub-Actions/set-output-Truncates-Multiline-Strings/td-p/37870 +# See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-of-a-multiline-string +# and https://github.com/github/docs/issues/21529#issue-1418590935 FILTERED_CHANGELOG=`echo "$CHANGELOG" | grep -v "\\[INFO\\]"` -FILTERED_CHANGELOG="${FILTERED_CHANGELOG//'%'/'%25'}" -FILTERED_CHANGELOG="${FILTERED_CHANGELOG//$'\n'/'%0A'}" -FILTERED_CHANGELOG="${FILTERED_CHANGELOG//$'\r'/'%0D'}" -echo "::set-output name=changelog::${FILTERED_CHANGELOG}" +FILTERED_CHANGELOG="${FILTERED_CHANGELOG//$'\''/'"'}" +echo "changelog<> $GITHUB_OUTPUT +echo -e "$FILTERED_CHANGELOG" >> $GITHUB_OUTPUT +echo "CHANGELOGEOF" >> $GITHUB_OUTPUT echo "" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61d3861bd..5bf78a56b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,23 @@ jobs: strategy: fail-fast: false matrix: - python: [3.5, 3.6, 3.7, pypy3] + python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9'] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 + + - name: Set up Python 3.13 for emulator + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 + with: + python-version: '3.13' + - name: Setup functions emulator environment + run: | + python -m venv integration/emulators/functions/venv + source integration/emulators/functions/venv/bin/activate + pip install -r integration/emulators/functions/requirements.txt + deactivate - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: ${{ matrix.python }} - name: Install dependencies @@ -22,23 +33,30 @@ jobs: pip install -r requirements.txt - name: Test with pytest run: pytest - - name: Set up Node.js 10 - uses: actions/setup-node@v1 + - name: Set up Node.js 20 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 4.4.0 with: - node-version: 10.x - - name: Run integration tests against emulator - run: | - npm install -g firebase-tools - firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - + node-version: 20 + - name: Set up Java 21 + uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # 5.1.0 + with: + distribution: 'temurin' + java-version: '21' + check-latest: true + - name: Install firebase-tools + run: npm install -g firebase-tools + - name: Run Database emulator tests + run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' + - name: Run Functions emulator tests + run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python 3.7 - uses: actions/setup-python@v1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 + - name: Set up Python 3.9 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: - python-version: 3.7 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..d60b3cd0b --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,99 @@ +# Copyright 2021 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Nightly Builds + +on: + # Runs every day at 06:20 AM (PT) and 08:20 PM (PT) / 04:20 AM (UTC) and 02:20 PM (UTC) + # or on 'firebase_nightly_build' repository dispatch event. + schedule: + - cron: "20 4,14 * * *" + repository_dispatch: + types: [firebase_nightly_build] + +jobs: + nightly: + + runs-on: ubuntu-latest + + steps: + - name: Checkout source for staging + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install setuptools wheel + pip install tensorflow + pip install keras + pip install build + + - name: Run unit tests + run: pytest + + - name: Run integration tests + run: ./.github/scripts/run_integration_tests.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # Build the Python Wheel and the source distribution. + - name: Package release artifacts + run: python -m build + + # Attach the packaged artifacts to the workflow output. These can be manually + # downloaded for later inspection if necessary. + - name: Archive artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist + + - name: Send email on failure + if: failure() + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} failed!' + html: > + Nightly workflow ${{github.run_id}} failed on: ${{github.repository}} +

Navigate to the + failed workflow. + continue-on-error: true + + - name: Send email on cancelled + if: cancelled() + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} cancelled!' + html: > + Nightly workflow ${{github.run_id}} cancelled on: ${{github.repository}} +

Navigate to the + cancelled workflow. + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbde8ed59..6bbf19aab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,18 @@ name: Release Candidate on: - # Only run the workflow when a PR is updated or when a developer explicitly requests - # a build by sending a 'firebase_build' event. + # Run the workflow when: + # 1. A PR is created or updated (staging checks). + # 2. A commit is pushed to main (release publication). + # 3. A developer explicitly requests a build via 'firebase_build' event. pull_request: - types: [opened, synchronize, closed] + types: [opened, synchronize] + + push: + branches: + - main + paths: + - 'firebase_admin/__about__.py' repository_dispatch: types: @@ -26,28 +34,24 @@ on: jobs: stage_release: - # To publish a release, merge the release PR with the label 'release:publish'. + # To publish a release, merge a PR with the title prefix '[chore] Release ' to main + # and ensure the squashed commit message also has the prefix. # To stage a release without publishing it, send a 'firebase_build' event or apply # the 'release:stage' label to a PR. if: github.event.action == 'firebase_build' || contains(github.event.pull_request.labels.*.name, 'release:stage') || - (github.event.pull_request.merged && - contains(github.event.pull_request.labels.*.name, 'release:publish')) + (github.event_name == 'push' && startsWith(github.event.head_commit.message, '[chore] Release ')) runs-on: ubuntu-latest - # When manually triggering the build, the requester can specify a target branch or a tag - # via the 'ref' client parameter. steps: - name: Checkout source for staging - uses: actions/checkout@v2 - with: - ref: ${{ github.event.client_payload.ref || github.ref }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: - python-version: 3.6 + python-version: 3.9 - name: Install dependencies run: | @@ -56,6 +60,7 @@ jobs: pip install setuptools wheel pip install tensorflow pip install keras + pip install build - name: Run unit tests run: pytest @@ -68,12 +73,12 @@ jobs: # Build the Python Wheel and the source distribution. - name: Package release artifacts - run: python setup.py bdist_wheel sdist + run: python -m build # Attach the packaged artifacts to the workflow output. These can be manually # downloaded for later inspection if necessary. - name: Archive artifacts - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist @@ -81,63 +86,48 @@ jobs: publish_release: needs: stage_release - # Check whether the release should be published. We publish only when the trigger PR is - # 1. merged - # 2. to the master branch - # 3. with the label 'release:publish', and - # 4. the title prefix '[chore] Release '. - if: github.event.pull_request.merged && - github.ref == 'master' && - contains(github.event.pull_request.labels.*.name, 'release:publish') && - startsWith(github.event.pull_request.title, '[chore] Release ') + # Check whether the release should be published. We publish only when the trigger is + # 1. a push (merge) + # 2. to the main branch + # 3. and the commit message has the title prefix '[chore] Release '. + if: github.event_name == 'push' && + github.ref == 'refs/heads/main' && + startsWith(github.event.head_commit.message, '[chore] Release ') runs-on: ubuntu-latest + environment: Release + permissions: + # Used to create a short-lived OIDC token which is given to PyPi to identify this workflow job + # See: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings + # and https://docs.pypi.org/trusted-publishers/using-a-publisher/ + id-token: write + contents: write steps: - name: Checkout source for publish - uses: actions/checkout@v2 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 # Download the artifacts created by the stage_release job. - name: Download release candidates - uses: actions/download-artifact@v1 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist + path: dist - name: Publish preflight check id: preflight run: ./.github/scripts/publish_preflight_check.sh - # We pull this action from a custom fork of a contributor until - # https://github.com/actions/create-release/pull/32 is merged. Also note that v1 of - # this action does not support the "body" parameter. + # See: https://cli.github.com/manual/gh_release_create - name: Create release tag - uses: fleskesvor/create-release@1a72e235c178bf2ae6c51a8ae36febc24568c5fe env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.preflight.outputs.version }} - release_name: Firebase Admin Python SDK ${{ steps.preflight.outputs.version }} - body: ${{ steps.preflight.outputs.changelog }} - draft: false - prerelease: false + RELEASE_VER: ${{ steps.preflight.outputs.version }} + RELEASE_BODY: ${{ steps.preflight.outputs.changelog }} + run: | + gh release create "$RELEASE_VER" \ + --title "Firebase Admin Python SDK $RELEASE_VER" \ + --notes "$RELEASE_BODY" - name: Publish to Pypi - uses: pypa/gh-action-pypi-publish@v1.0.0a0 - with: - user: firebase - password: ${{ secrets.PYPI_PASSWORD }} - - # Post to Twitter if explicitly opted-in by adding the label 'release:tweet'. - - name: Post to Twitter - if: success() && - contains(github.event.pull_request.labels.*.name, 'release:tweet') - uses: firebase/firebase-admin-node/.github/actions/send-tweet@master - with: - status: > - ${{ steps.preflight.outputs.version }} of @Firebase Admin Python SDK is available. - https://github.com/firebase/firebase-admin-python/releases/tag/${{ steps.preflight.outputs.version }} - consumer-key: ${{ secrets.TWITTER_CONSUMER_KEY }} - consumer-secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} - access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} - access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} - continue-on-error: true + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.gitignore b/.gitignore index 79d2d5ff3..d9d47dc51 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ apikey.txt htmlcov/ .pytest_cache/ .vscode/ +.venv/ +.DS_Store diff --git a/.pylintrc b/.pylintrc index 2155853c7..ea54e481c 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,4 @@ -[MASTER] +[MAIN] # Specify a configuration file. #rcfile= @@ -20,7 +20,9 @@ persistent=no # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. -load-plugins=pylint.extensions.docparams,pylint.extensions.docstyle +load-plugins=pylint.extensions.docparams, + pylint.extensions.docstyle, + pylint.extensions.bad_builtin, # Use multiple processes to speed up Pylint. jobs=1 @@ -34,15 +36,6 @@ unsafe-load-any-extension=no # run arbitrary code extension-pkg-whitelist= -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. This option is deprecated -# and it will be removed in Pylint 2.0. -optimize-ast=no - [MESSAGES CONTROL] @@ -65,21 +58,31 @@ enable=indexing-exception,old-raise-syntax # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,file-ignored,missing-type-doc +disable=design, + similarities, + no-self-use, + attribute-defined-outside-init, + locally-disabled, + star-args, + pointless-except, + bad-option-value, + lobal-statement, + fixme, + suppressed-message, + useless-suppression, + locally-enabled, + file-ignored, + missing-type-doc, + c-extension-no-member, [REPORTS] -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". This option is deprecated -# and it will be removed in Pylint 2.0. -files-output=no +# Set the output format. Available formats are: 'text', 'parseable', +# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs +# (visual studio) and 'github' (GitHub actions). You can also give a reporter +# class, e.g. mypackage.mymodule.MyReporterClass. +output-format=colorized # Tells whether to display a full report or only the messages reports=no @@ -176,9 +179,12 @@ logging-modules=logging good-names=main,_ # Bad variable names which should always be refused, separated by a comma -bad-names= - -bad-functions=input,apply,reduce +bad-names=foo, + bar, + baz, + toto, + tutu, + tata # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. @@ -194,64 +200,33 @@ property-classes=abc.abstractproperty # Regular expression matching correct function names function-rgx=[a-z_][a-z0-9_]*$ -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]*$ - # Regular expression matching correct variable names variable-rgx=[a-z_][a-z0-9_]{2,30}$ -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct constant names const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - # Regular expression matching correct attribute names attr-rgx=[a-z_][a-z0-9_]{2,30}$ -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct argument names argument-rgx=[a-z_][a-z0-9_]{2,30}$ -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ - # Regular expression matching correct class attribute names class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - # Regular expression matching correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - # Regular expression matching correct method names method-rgx=[a-z_][a-z0-9_]*$ -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]*$ - # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=(__.*__|main) @@ -294,12 +269,6 @@ ignore-long-lines=^\s*(# )??$ # else. single-line-if-stmt=no -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - # Maximum number of lines in a module max-module-lines=1000 @@ -405,6 +374,12 @@ exclude-protected=_asdict,_fields,_replace,_source,_make [EXCEPTIONS] -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + +[DEPRECATED_BUILTINS] + +# List of builtins function names that should not be used, separated by a comma +bad-functions=input, + apply, + reduce diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..28bba4b55 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# Firebase Admin Python SDK - Agent Guide + +This document provides AI agents with a comprehensive guide to the conventions, design patterns, and architectural nuances of the Firebase Admin Python SDK. Adhering to this guide ensures that all contributions are idiomatic and align with the existing codebase. + +## 1. High-Level Overview + +The Firebase Admin Python SDK provides a Pythonic interface to Firebase services. Its design emphasizes thread-safety, a consistent and predictable API, and seamless integration with Google Cloud Platform services. + +## 2. Directory Structure + +- `firebase_admin/`: The main package directory. + - `__init__.py`: The primary entry point. It exposes the `initialize_app()` function and manages the lifecycle of `App` instances. + - `exceptions.py`: Defines the custom exception hierarchy for the SDK. + - `_http_client.py`: Contains the centralized `JsonHttpClient` and `HttpxAsyncClient` for all outgoing HTTP requests. + - Service modules (e.g., `auth.py`, `db.py`, `messaging.py`): Each module contains the logic for a specific Firebase service. +- `tests/`: Contains all unit tests. + - `tests/resources/`: Contains mock data, keys, and other test assets. +- `integration/`: Contains all integration tests.* + - These integration tests require a real Firebase project to run against. + - `integration/conftest.py`: Contains provides configurations for these integration tests including how credentials are provided through pytest. +- `snippets/`: Contains code snippets used in documentation. +- `setup.py`: Package definition, including the required environment dependencies. +- `requirements.txt`: A list of all development dependencies. +- `.pylintrc`: Configuration file for the `pylint` linter. +- `CONTRIBUTING.md`: General guidelines for human contributors. Your instructions here supersede this file. + +## 3. Core Design Patterns + +### Initialization + +The SDK is initialized by calling the `initialize_app(credential, options)` function. This creates a default `App` instance that SDK modules use implicitly. For multi-project use cases, named apps can be created by providing a `name` argument: `initialize_app(credential, options, name='my_app')`. + +### Service Clients + +Service clients are accessed via module-level factory functions. These functions automatically use the default app unless a specific `App` object is provided via the `app` parameter. The clients are created lazily and cached for the lifetime of the application. + +- **Direct Action Modules (auth, db)**: Some modules provide functions that perform actions directly. +- **Client Factory Modules (firestore, storage)**: Other modules have a function (e.g., client() or bucket()) that returns a client object, which you then use for operations. + + +### Error Handling + +- All SDK-specific exceptions inherit from `firebase_admin.exceptions.FirebaseError`. +- Specific error conditions are represented by subclasses, such as `firebase_admin.exceptions.InvalidArgumentError` and `firebase_admin.exceptions.UnauthenticatedError`. +- Each service may additionaly define exceptions under these subclasses and apply them by passing a handle function to `_utils.handle_platform_error_from_requests()` or `_utils.handle_platform_error_from_httpx()`. Each services error handling patterns should be considered before making changes. + +### HTTP Communication + +- All synchronous HTTP requests are made through the `JsonHttpClient` class in `firebase_admin._http_client`. +- All asynchronous HTTP requests are made through the `HttpxAsyncClient` class in `firebase_admin._http_client`. +- These clients handle authentication and retries for all API calls. + +### Asynchronous Operations + +Asynchronous operations are supported using Python's `asyncio` library. Asynchronous methods are typically named with an `_async` suffix (e.g., `messaging.send_each_async()`). + +## 4. Coding Style and Naming Conventions + +- **Formatting:** This project uses **pylint** to enforce code style and detect potential errors. Before submitting code, you **must** run the linter and ensure your changes do not introduce any new errors. Run the linter from the repository's root directory with the following command: + ```bash + ./lint.sh all # Lint all source files + ``` + or + ```bash + ./lint.sh # Lint locally modified source files + ``` +- **Naming:** + - Classes: `PascalCase` (e.g., `FirebaseError`). + - Methods and Functions: `snake_case` (e.g., `initialize_app`). + - Private Members: An underscore prefix (e.g., `_http_client`). + - Constants: `UPPER_SNAKE_CASE` (e.g., `INVALID_ARGUMENT`). + +## 5. Testing Philosophy + +- **Unit Tests:** + - Located in the `tests/` directory. + - Test files follow the `test_*.py` naming convention. + - Unit tests can be run using the following command: + ```bash + pytest + ``` +- **Integration Tests:** + - Located in the `integration/` directory. + - These tests make real API calls to Firebase services and require a configured project. Running these tests be should be ignored without a project and instead rely on the repository's GitHub Actions. + +## 6. Dependency Management + +- **Manager:** `pip` +- **Manifest:** `requirements.txt` +- **Command:** `pip install -r requirements.txt` + +## 7. Critical Developer Journeys + +### Journey 1: How to Add a New API Method + +1. **Define Public Method:** Add the new method or change to the appropriate service client files (e.g., `firebase_admin/auth.py`). +2. **Expose the public API method** by updating the `__all__` constant with the name of the new method. +3. **Internal Logic:** Implement the core logic within the service package. +4. **HTTP Client:** Use the HTTP client (`JsonHttpClient` or `HttpxAsyncClient`) to make the API call. +5. **Error Handling:** Catching exceptions from the HTTP client and raise the appropriate `FirebaseError` subclass using the services error handling logic +6. **Testing:** + - Add unit tests in the corresponding `test_*.py` file (e.g., `tests/test_user_mgt.py`). + - Add integration tests in the `integration/` directory if applicable. +7. **Snippets:** (Optional) Add or update code snippets in the `snippets/` directory. + +### Journey 2: How to Deprecate a Field/Method in an Existing API + +1. **Add Deprecation Note:** Locate where the deprecated object is defined and add a deprecation note to its docstring (e.g. `X is deprecated. Use Y instead.`). +2. **Add Deprecation Warning:** In the same location where the deprecated object is defined, add a deprecation warning to the code. (e.g. `warnings.warn('X is deprecated. Use Y instead.', DeprecationWarning)`) + +## 8. Critical Do's and Don'ts + +- **DO:** Use the centralized `JsonHttpClient` or `HttpxAsyncClient` for all HTTP requests. +- **DO:** Follow the established error handling patterns by using `FirebaseError` and its subclasses. +- **DON'T:** Expose implementation details from private (underscored) modules or functions in the public API. +- **DON'T:** Introduce new third-party dependencies without updating `requirements.txt` and `setup.py`. + +## 9. Branch Creation +- When creating a new barnch use the format `agentName-short-description`. + * Example: `jules-auth-token-parsing` + * Example: `gemini-add-storage-file-signer` + +## 10. Commit and Pull Request Generation + +After implementing and testing a change, you may create a commit and pull request which must follow the following these rules: + +### Commit and Pull Request Title Format: +Use the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification: `type(scope): subject` +- `type` should be one of `feat`, `fix` or `chore`. +- `scope` should be the service package changed (e.g., `auth`, `rtdb`, `deps`). + - **Note**: Some services use specific abbreviations. Use the abbreviation if one exists. Common abbreviations include: + - `messaging` -> `fcm` + - `dataconnect` -> `fdc` + - `database` -> `rtdb` + - `appcheck` -> `fac` +- `subject` should be a brief summary of the change depending on the action: + - For pull requests this should focus on the larger goal the included commits achieve. + - Example: `fix(auth): Resolved issue with custom token verification` + - For commits this should focus on the specific changes made in that commit. + - Example: `fix(auth): Added a new token verification check` + +### Commit Body: +This should be a brief explanation of code changes. + +Example: +``` +feat(fcm): Added `send_each_for_multicast` support for multicast messages + +Added a new `send_each_for_multicast` method to the messaging client. This method wraps the `send_each` method and sends the same message to each token. +``` + +### Pull Request Body: +- A brief explanation of the problem and the solution. +- A summary of the testing strategy (e.g., "Added a new unit test to verify the fix."). +- A **Context Sources** section that lists the `id` and repository path of every `AGENTS.md` file you used. + +Example: +``` +feat(fcm): Added support for multicast messages + +This change introduces a new `send_each_for_multicast` method to the messaging client, allowing developers to send a single message to multiple tokens efficiently. + +Testing: Added unit tests in `tests/test_messaging.py` with mock requests and an integration test in `integration/test_messaging.py`. + +Context Sources Used: +- id: firebase-admin-python +``` + +## 11. Metadata +- id: firebase-admin-python \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6d09b093..139e7f96c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Great, we love hearing how we can improve our products! Share you idea through o ## Want to submit a pull request? Sweet, we'd love to accept your contribution! -[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new/master) and fill +[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new) and fill out the provided template. **If you want to implement a new feature, please open an issue with a proposal first so that we can @@ -85,7 +85,7 @@ information on using pull requests. ### Initial Setup -You need Python 3.4+ to build and test the code in this repo. +You need Python 3.9+ to build and test the code in this repo. We recommend using [pip](https://pypi.python.org/pypi/pip) for installing the necessary tools and project dependencies. Most recent versions of Python ship with pip. If your development environment @@ -162,44 +162,106 @@ pytest --cov=firebase_admin --cov=tests ### Integration Testing -A suite of integration tests are available under the `integration/` directory. -These tests are designed to run against an actual Firebase project. Create a new -project in the [Firebase Console](https://console.firebase.google.com), if you -do not already have one suitable for running the tests aginst. Then obtain the -following credentials from the project: - -1. *Service account certificate*: This can be downloaded as a JSON file from - the "Settings > Service Accounts" tab of the Firebase console. Copy the - file into the repo so it's available at `cert.json`. -2. *Web API key*: This is displayed in the "Settings > General" tab of the - console. Copy it and save to a new text file at `apikey.txt`. - -Then set up your Firebase/GCP project as follows: - -1. Enable Firestore: Go to the Firebase Console, and select "Database" from - the "Develop" menu. Click on the "Create database" button. You may choose - to set up Firestore either in the locked mode or in the test mode. -2. Enable password auth: Select "Authentication" from the "Develop" menu in - Firebase Console. Select the "Sign-in method" tab, and enable the - "Email/Password" sign-in method, including the Email link (passwordless - sign-in) option. -3. Enable the Firebase ML API: Go to the - [Google Developers Console]( - https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview) - and make sure your project is selected. If the API is not already enabled, click Enable. -4. Enable the IAM API: Go to the - [Google Cloud Platform Console](https://console.cloud.google.com) and make - sure your Firebase/GCP project is selected. Select "APIs & Services > - Dashboard" from the main menu, and click the "ENABLE APIS AND SERVICES" - button. Search for and enable the "Identity and Access Management (IAM) - API". -5. Grant your service account the 'Firebase Authentication Admin' role. This is - required to ensure that exported user records contain the password hashes of - the user accounts: - 1. Go to [Google Cloud Platform Console / IAM & admin](https://console.cloud.google.com/iam-admin). - 2. Find your service account in the list, and click the 'pencil' icon to edit it's permissions. - 3. Click 'ADD ANOTHER ROLE' and choose 'Firebase Authentication Admin'. - 4. Click 'SAVE'. + +Integration tests are executed against a real life Firebase project. If you do not already +have one suitable for running the tests against, you can create a new project in the +[Firebase Console](https://console.firebase.google.com) following the setup guide below. +If you already have a Firebase project, you'll need to obtain credentials to communicate and +authorize access to your Firebase project: + + +1. Service account certificate: This allows access to your Firebase project through a service account +which is required for all integration tests. This can be downloaded as a JSON file from the +**Settings > Service Accounts** tab of the Firebase console when you click the +**Generate new private key** button. Copy the file into the repo so it's available at `cert.json`. + > **Note:** Service accounts should be carefully managed and their keys should never be stored in publicly accessible source code or repositories. + + +2. Web API key: This allows for Auth sign-in needed for some Authentication and Tenant Management +integration tests. This is displayed in the **Settings > General** tab of the Firebase console +after enabling Authentication as described in the steps below. Copy it and save to a new text +file at `apikey.txt`. + + +Set up your Firebase project as follows: + + +1. Enable Authentication: + 1. Go to the Firebase Console, and select **Authentication** from the **Build** menu. + 2. Click on **Get Started**. + 3. Select **Sign-in method > Add new provider > Email/Password** then enable both the + **Email/Password** and **Email link (passwordless sign-in)** options. + + +2. Enable Firestore: + 1. Go to the Firebase Console, and select **Firestore Database** from the **Build** menu. + 2. Click on the **Create database** button. You can choose to set up Firestore either in + the production mode or in the test mode. + + +3. Enable Realtime Database: + 1. Go to the Firebase Console, and select **Realtime Database** from the **Build** menu. + 2. Click on the **Create Database** button. You can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + > **Note:** Integration tests are not run against the default Realtime Database reference and are + instead run against a database created at `https://{PROJECT_ID}.firebaseio.com`. + This second Realtime Database reference is created in the following steps. + + 3. In the **Data** tab click on the kebab menu (3 dots) and select **Create Database**. + 4. Enter your Project ID (Found in the **General** tab in **Account Settings**) as the + **Realtime Database reference**. Again, you can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + +4. Enable Storage: + 1. Go to the Firebase Console, and select **Storage** from the **Build** menu. + 2. Click on the **Get started** button. You can choose to set up Cloud Storage + either in the production mode or in the test mode. + + +5. Enable the Firebase ML API: + 1. Go to the + [Google Cloud console | Firebase ML API](https://console.cloud.google.com/apis/api/firebaseml.googleapis.com/overview) + and make sure your project is selected. + 2. If the API is not already enabled, click **Enable**. + + +6. Enable the IAM API: + 1. Go to the [Google Cloud console](https://console.cloud.google.com) + and make sure your Firebase project is selected. + 2. Select **APIs & Services** from the main menu, and click the + **ENABLE APIS AND SERVICES** button. + 3. Search for and enable **Identity and Access Management (IAM) API** by Google Enterprise API. + + +7. Enable Tenant Management: + 1. Go to + [Google Cloud console | Identity Platform](https://console.cloud.google.com/customer-identity/) + and if it is not already enabled, click **Enable**. + 2. Then + [enable multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-quickstart#enabling_multi-tenancy) + for your project. + + +8. Ensure your service account has the **Firebase Authentication Admin** role. This is required +to ensure that exported user records contain the password hashes of the user accounts: + 1. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin). + 2. Find your service account in the list. If not added click the pencil icon to edit its + permissions. + 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. + 4. Click **SAVE**. + +9. Enable Cloud Tasks: + 1. Search for and enable **Cloud Run**. + 2. Search for and enable **Cloud Tasks**. + 3. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin) + and make sure your Firebase project is selected. + 4. Ensure your service account has the following required roles: + * **Cloud Tasks Enqueuer** - `cloudtasks.taskEnqueuer` + * **Cloud Tasks Task Deleter** - `cloudtasks.taskDeleter` + * **Cloud Run Invoker** - `run.invoker` + * **Service Account User** - `iam.serviceAccountUser` Now you can invoke the integration test suite as follows: diff --git a/README.md b/README.md index 7f33af68b..29303fd4f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/firebase/firebase-admin-python.svg?branch=master)](https://travis-ci.org/firebase/firebase-admin-python) +[![Nightly Builds](https://github.com/firebase/firebase-admin-python/actions/workflows/nightly.yml/badge.svg)](https://github.com/firebase/firebase-admin-python/actions/workflows/nightly.yml) [![Python](https://img.shields.io/pypi/pyversions/firebase-admin.svg)](https://pypi.org/project/firebase-admin/) [![Version](https://img.shields.io/pypi/v/firebase-admin.svg)](https://pypi.org/project/firebase-admin/) @@ -43,8 +43,10 @@ requests, code review feedback, and also pull requests. ## Supported Python Versions -We currently support Python 3.5+. Firebase Admin Python SDK is also tested on -PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments. +We currently support Python 3.9+. However, Python 3.9 support is deprecated, +and developers are strongly advised to use Python 3.10 or higher. Firebase +Admin Python SDK is also tested on PyPy and +[Google App Engine](https://cloud.google.com/appengine/) environments. ## Documentation diff --git a/firebase_admin/__about__.py b/firebase_admin/__about__.py index 298f3703e..d219f5ed7 100644 --- a/firebase_admin/__about__.py +++ b/firebase_admin/__about__.py @@ -14,7 +14,7 @@ """About information (version, etc) for Firebase Admin SDK.""" -__version__ = '4.3.0' +__version__ = '7.2.0' __title__ = 'firebase_admin' __author__ = 'Firebase' __license__ = 'Apache License 2.0' diff --git a/firebase_admin/__init__.py b/firebase_admin/__init__.py index 7e3b2eab0..8c9f628e5 100644 --- a/firebase_admin/__init__.py +++ b/firebase_admin/__init__.py @@ -18,6 +18,8 @@ import os import threading +from google.auth.credentials import Credentials as GoogleAuthCredentials +from google.auth.exceptions import DefaultCredentialsError from firebase_admin import credentials from firebase_admin.__about__ import __version__ @@ -49,8 +51,9 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME): Google Application Default Credentials are used. options: A dictionary of configuration options (optional). Supported options include ``databaseURL``, ``storageBucket``, ``projectId``, ``databaseAuthVariableOverride``, - ``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, the SDK - uses a default timeout of 120 seconds. + ``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, the SDK uses + a default timeout of 120 seconds. + name: Name of the app (optional). Returns: App: A newly initialized instance of App. @@ -76,11 +79,11 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME): 'apps, pass a second argument to initialize_app() to give each app ' 'a unique name.')) - raise ValueError(( - 'Firebase app named "{0}" already exists. This means you called ' + raise ValueError( + f'Firebase app named "{name}" already exists. This means you called ' 'initialize_app() more than once with the same app name as the ' 'second argument. Make sure you provide a unique name every time ' - 'you call initialize_app().').format(name)) + 'you call initialize_app().') def delete_app(app): @@ -93,8 +96,7 @@ def delete_app(app): ValueError: If the app is not initialized. """ if not isinstance(app, App): - raise ValueError('Illegal app argument type: "{}". Argument must be of ' - 'type App.'.format(type(app))) + raise ValueError(f'Illegal app argument type: "{type(app)}". Argument must be of type App.') with _apps_lock: if _apps.get(app.name) is app: del _apps[app.name] @@ -106,9 +108,9 @@ def delete_app(app): 'the default app by calling initialize_app().') raise ValueError( - ('Firebase app named "{0}" is not initialized. Make sure to initialize ' - 'the app by calling initialize_app() with your app name as the ' - 'second argument.').format(app.name)) + f'Firebase app named "{app.name}" is not initialized. Make sure to initialize ' + 'the app by calling initialize_app() with your app name as the ' + 'second argument.') def get_app(name=_DEFAULT_APP_NAME): @@ -125,8 +127,8 @@ def get_app(name=_DEFAULT_APP_NAME): app does not exist. """ if not isinstance(name, str): - raise ValueError('Illegal app name argument type: "{}". App name ' - 'must be a string.'.format(type(name))) + raise ValueError( + f'Illegal app name argument type: "{type(name)}". App name must be a string.') with _apps_lock: if name in _apps: return _apps[name] @@ -137,9 +139,9 @@ def get_app(name=_DEFAULT_APP_NAME): 'the SDK by calling initialize_app().') raise ValueError( - ('Firebase app named "{0}" does not exist. Make sure to initialize ' - 'the SDK by calling initialize_app() with your app name as the ' - 'second argument.').format(name)) + f'Firebase app named "{name}" does not exist. Make sure to initialize ' + 'the SDK by calling initialize_app() with your app name as the ' + 'second argument.') class _AppOptions: @@ -150,8 +152,9 @@ def __init__(self, options): options = self._load_from_environment() if not isinstance(options, dict): - raise ValueError('Illegal Firebase app options type: {0}. Options ' - 'must be a dictionary.'.format(type(options))) + raise ValueError( + f'Illegal Firebase app options type: {type(options)}. ' + 'Options must be a dictionary.') self._options = options def get(self, key, default=None): @@ -172,14 +175,15 @@ def _load_from_environment(self): json_str = config_file else: try: - with open(config_file, 'r') as json_file: + with open(config_file, 'r', encoding='utf-8') as json_file: json_str = json_file.read() except Exception as err: - raise ValueError('Unable to read file {}. {}'.format(config_file, err)) + raise ValueError(f'Unable to read file {config_file}. {err}') from err try: json_data = json.loads(json_str) except Exception as err: - raise ValueError('JSON string "{0}" is not valid json. {1}'.format(json_str, err)) + raise ValueError( + f'JSON string "{json_str}" is not valid json. {err}') from err return {k: v for k, v in json_data.items() if k in _CONFIG_VALID_KEYS} @@ -202,14 +206,18 @@ def __init__(self, name, credential, options): ValueError: If an argument is None or invalid. """ if not name or not isinstance(name, str): - raise ValueError('Illegal Firebase app name "{0}" provided. App name must be a ' - 'non-empty string.'.format(name)) + raise ValueError( + f'Illegal Firebase app name "{name}" provided. App name must be a ' + 'non-empty string.') self._name = name - if not isinstance(credential, credentials.Base): + if isinstance(credential, GoogleAuthCredentials): + self._credential = credentials._ExternalCredentials(credential) # pylint: disable=protected-access + elif isinstance(credential, credentials.Base): + self._credential = credential + else: raise ValueError('Illegal Firebase credential provided. App must be initialized ' 'with a valid credential instance.') - self._credential = credential self._options = _AppOptions(options) self._lock = threading.RLock() self._services = {} @@ -221,7 +229,7 @@ def __init__(self, name, credential, options): def _validate_project_id(cls, project_id): if project_id is not None and not isinstance(project_id, str): raise ValueError( - 'Invalid project ID: "{0}". project ID must be a string.'.format(project_id)) + f'Invalid project ID: "{project_id}". project ID must be a string.') @property def name(self): @@ -257,7 +265,7 @@ def _lookup_project_id(self): if not project_id: try: project_id = self._credential.project_id - except AttributeError: + except (AttributeError, DefaultCredentialsError): pass if not project_id: project_id = os.environ.get('GOOGLE_CLOUD_PROJECT', @@ -286,11 +294,11 @@ def _get_service(self, name, initializer): """ if not name or not isinstance(name, str): raise ValueError( - 'Illegal name argument: "{0}". Name must be a non-empty string.'.format(name)) + f'Illegal name argument: "{name}". Name must be a non-empty string.') with self._lock: if self._services is None: raise ValueError( - 'Service requested from deleted Firebase App: "{0}".'.format(self._name)) + f'Service requested from deleted Firebase App: "{self._name}".') if name not in self._services: self._services[name] = initializer(self) return self._services[name] diff --git a/firebase_admin/_auth_client.py b/firebase_admin/_auth_client.py index 1c9b37082..74261fa37 100644 --- a/firebase_admin/_auth_client.py +++ b/firebase_admin/_auth_client.py @@ -24,6 +24,7 @@ from firebase_admin import _user_identifier from firebase_admin import _user_import from firebase_admin import _user_mgt +from firebase_admin import _utils class Client: @@ -36,17 +37,37 @@ def __init__(self, app, tenant_id=None): 2. set the project ID explicitly via Firebase App options, or 3. set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.""") - credential = app.credential.get_credential() - version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) + credential = None + version_header = f'Python/Admin/{firebase_admin.__version__}' + timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) + # Non-default endpoint URLs for emulator support are set in this dict later. + endpoint_urls = {} + self.emulated = False + + # If an emulator is present, check that the given value matches the expected format and set + # endpoint URLs to use the emulator. Additionally, use a fake credential. + emulator_host = _auth_utils.get_emulator_host() + if emulator_host: + base_url = f'http://{emulator_host}/identitytoolkit.googleapis.com' + endpoint_urls['v1'] = base_url + '/v1' + endpoint_urls['v2'] = base_url + '/v2' + credential = _utils.EmulatorAdminCredentials() + self.emulated = True + else: + # Use credentials if provided + credential = app.credential.get_credential() + http_client = _http_client.JsonHttpClient( - credential=credential, headers={'X-Client-Version': version_header}) + credential=credential, headers={'X-Client-Version': version_header}, timeout=timeout) self._tenant_id = tenant_id - self._token_generator = _token_gen.TokenGenerator(app, http_client) + self._token_generator = _token_gen.TokenGenerator( + app, http_client, url_override=endpoint_urls.get('v1')) self._token_verifier = _token_gen.TokenVerifier(app) - self._user_manager = _user_mgt.UserManager(http_client, app.project_id, tenant_id) + self._user_manager = _user_mgt.UserManager( + http_client, app.project_id, tenant_id, url_override=endpoint_urls.get('v1')) self._provider_manager = _auth_providers.ProviderConfigClient( - http_client, app.project_id, tenant_id) + http_client, app.project_id, tenant_id, url_override=endpoint_urls.get('v2')) @property def tenant_id(self): @@ -71,7 +92,7 @@ def create_custom_token(self, uid, developer_claims=None): return self._token_generator.create_custom_token( uid, developer_claims, tenant_id=self.tenant_id) - def verify_id_token(self, id_token, check_revoked=False): + def verify_id_token(self, id_token, check_revoked=False, clock_skew_seconds=0): """Verifies the signature and data for the provided JWT. Accepts a signed token string, verifies that it is current, was issued @@ -79,7 +100,10 @@ def verify_id_token(self, id_token, check_revoked=False): Args: id_token: A string of the encoded JWT. - check_revoked: Boolean, If true, checks whether the token has been revoked (optional). + check_revoked: Boolean, If true, checks whether the token has been revoked or + the user disabled (optional). + clock_skew_seconds: The number of seconds to tolerate when checking the token. + Must be between 0-60. Defaults to 0. Returns: dict: A dictionary of key-value pairs parsed from the decoded JWT. @@ -94,21 +118,25 @@ def verify_id_token(self, id_token, check_revoked=False): this ``Client`` instance. CertificateFetchError: If an error occurs while fetching the public key certificates required to verify the ID token. + UserDisabledError: If ``check_revoked`` is ``True`` and the corresponding user + record is disabled. """ if not isinstance(check_revoked, bool): # guard against accidental wrong assignment. - raise ValueError('Illegal check_revoked argument. Argument must be of type ' - ' bool, but given "{0}".'.format(type(check_revoked))) + raise ValueError( + 'Illegal check_revoked argument. Argument must be of type bool, but given ' + f'"{type(check_revoked)}".') - verified_claims = self._token_verifier.verify_id_token(id_token) + verified_claims = self._token_verifier.verify_id_token(id_token, clock_skew_seconds) if self.tenant_id: token_tenant_id = verified_claims.get('firebase', {}).get('tenant') if self.tenant_id != token_tenant_id: raise _auth_utils.TenantIdMismatchError( - 'Invalid tenant ID: {0}'.format(token_tenant_id)) + f'Invalid tenant ID: {token_tenant_id}') if check_revoked: - self._check_jwt_revoked(verified_claims, _token_gen.RevokedIdTokenError, 'ID token') + self._check_jwt_revoked_or_disabled( + verified_claims, _token_gen.RevokedIdTokenError, 'ID token') return verified_claims def revoke_refresh_tokens(self, uid): @@ -160,7 +188,7 @@ def get_user_by_email(self, email): Raises: ValueError: If the email is None, empty or malformed. - UserNotFoundError: If no user exists by the specified email address. + UserNotFoundError: If no user exists for the specified email address. FirebaseError: If an error occurs while retrieving the user. """ response = self._user_manager.get_user(email=email) @@ -177,7 +205,7 @@ def get_user_by_phone_number(self, phone_number): Raises: ValueError: If the phone number is ``None``, empty or malformed. - UserNotFoundError: If no user exists by the specified phone number. + UserNotFoundError: If no user exists for the specified phone number. FirebaseError: If an error occurs while retrieving the user. """ response = self._user_manager.get_user(phone_number=phone_number) @@ -222,7 +250,7 @@ def _matches(identifier, user_record): if identifier.provider_id == user_info.provider_id and identifier.provider_uid == user_info.uid ), False) - raise TypeError("Unexpected type: {}".format(type(identifier))) + raise TypeError(f"Unexpected type: {type(identifier)}") def _is_user_found(identifier, user_records): return any(_matches(identifier, user_record) for user_record in user_records) @@ -263,7 +291,7 @@ def create_user(self, **kwargs): # pylint: disable=differing-param-doc """Creates a new user account with the specified properties. Args: - kwargs: A series of keyword arguments (optional). + **kwargs: A series of keyword arguments (optional). Keyword Args: uid: User ID to assign to the newly created user (optional). @@ -291,7 +319,7 @@ def update_user(self, uid, **kwargs): # pylint: disable=differing-param-doc Args: uid: A user ID string. - kwargs: A series of keyword arguments (optional). + **kwargs: A series of keyword arguments (optional). Keyword Args: display_name: The user's display name (optional). Can be removed by explicitly passing @@ -311,6 +339,8 @@ def update_user(self, uid, **kwargs): # pylint: disable=differing-param-doc valid_since: An integer signifying the seconds since the epoch (optional). This field is set by ``revoke_refresh_tokens`` and it is discouraged to set this field directly. + providers_to_delete: The list of provider IDs to unlink, + eg: 'google.com', 'password', etc. Returns: UserRecord: An updated UserRecord instance for the user. @@ -423,6 +453,7 @@ def generate_password_reset_link(self, email, action_code_settings=None): Raises: ValueError: If the provided arguments are invalid + EmailNotFoundError: If no user exists for the specified email address. FirebaseError: If an error occurs while generating the link """ return self._user_manager.generate_email_action_link( @@ -443,6 +474,7 @@ def generate_email_verification_link(self, email, action_code_settings=None): Raises: ValueError: If the provided arguments are invalid + UserNotFoundError: If no user exists for the specified email address. FirebaseError: If an error occurs while generating the link """ return self._user_manager.generate_email_action_link( @@ -485,7 +517,8 @@ def get_oidc_provider_config(self, provider_id): return self._provider_manager.get_oidc_provider_config(provider_id) def create_oidc_provider_config( - self, provider_id, client_id, issuer, display_name=None, enabled=None): + self, provider_id, client_id, issuer, display_name=None, enabled=None, + client_secret=None, id_token_response_type=None, code_response_type=None): """Creates a new OIDC provider config from the given parameters. OIDC provider support requires Google Cloud's Identity Platform (GCIP). To learn more about @@ -499,6 +532,16 @@ def create_oidc_provider_config( This name is also used as the provider label in the Cloud Console. enabled: A boolean indicating whether the provider configuration is enabled or disabled (optional). A user cannot sign in using a disabled provider. + client_secret: A string which sets the client secret for the new provider. + This is required for the code flow. + code_response_type: A boolean which sets whether to enable the code response flow for + the new provider. By default, this is not enabled if no response type is + specified. A client secret must be set for this response type. + Having both the code and ID token response flows is currently not supported. + id_token_response_type: A boolean which sets whether to enable the ID token response + flow for the new provider. By default, this is enabled if no response type is + specified. + Having both the code and ID token response flows is currently not supported. Returns: OIDCProviderConfig: The newly created OIDC provider config instance. @@ -509,10 +552,12 @@ def create_oidc_provider_config( """ return self._provider_manager.create_oidc_provider_config( provider_id, client_id=client_id, issuer=issuer, display_name=display_name, - enabled=enabled) + enabled=enabled, client_secret=client_secret, + id_token_response_type=id_token_response_type, code_response_type=code_response_type) def update_oidc_provider_config( - self, provider_id, client_id=None, issuer=None, display_name=None, enabled=None): + self, provider_id, client_id=None, issuer=None, display_name=None, enabled=None, + client_secret=None, id_token_response_type=None, code_response_type=None): """Updates an existing OIDC provider config with the given parameters. Args: @@ -523,6 +568,16 @@ def update_oidc_provider_config( Pass ``auth.DELETE_ATTRIBUTE`` to delete the current display name. enabled: A boolean indicating whether the provider configuration is enabled or disabled (optional). + client_secret: A string which sets the client secret for the new provider. + This is required for the code flow. + code_response_type: A boolean which sets whether to enable the code response flow for + the new provider. By default, this is not enabled if no response type is specified. + A client secret must be set for this response type. + Having both the code and ID token response flows is currently not supported. + id_token_response_type: A boolean which sets whether to enable the ID token response + flow for the new provider. By default, this is enabled if no response type is + specified. + Having both the code and ID token response flows is currently not supported. Returns: OIDCProviderConfig: The updated OIDC provider config instance. @@ -533,7 +588,8 @@ def update_oidc_provider_config( """ return self._provider_manager.update_oidc_provider_config( provider_id, client_id=client_id, issuer=issuer, display_name=display_name, - enabled=enabled) + enabled=enabled, client_secret=client_secret, + id_token_response_type=id_token_response_type, code_response_type=code_response_type) def delete_oidc_provider_config(self, provider_id): """Deletes the ``OIDCProviderConfig`` with the given ID. @@ -697,7 +753,9 @@ def list_saml_provider_configs( """ return self._provider_manager.list_saml_provider_configs(page_token, max_results) - def _check_jwt_revoked(self, verified_claims, exc_type, label): + def _check_jwt_revoked_or_disabled(self, verified_claims, exc_type, label): user = self.get_user(verified_claims.get('uid')) + if user.disabled: + raise _auth_utils.UserDisabledError('The user record is disabled.') if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: - raise exc_type('The Firebase {0} has been revoked.'.format(label)) + raise exc_type(f'The Firebase {label} has been revoked.') diff --git a/firebase_admin/_auth_providers.py b/firebase_admin/_auth_providers.py index 46de6fe5f..cc7949526 100644 --- a/firebase_admin/_auth_providers.py +++ b/firebase_admin/_auth_providers.py @@ -59,6 +59,18 @@ def issuer(self): def client_id(self): return self._data['clientId'] + @property + def client_secret(self): + return self._data.get('clientSecret') + + @property + def id_token_response_type(self): + return self._data.get('responseType', {}).get('idToken', False) + + @property + def code_response_type(self): + return self._data.get('responseType', {}).get('code', False) + class SAMLProviderConfig(ProviderConfig): """Represents he SAML auth provider configuration. @@ -164,21 +176,23 @@ def items(self): class ProviderConfigClient: """Client for managing Auth provider configurations.""" - PROVIDER_CONFIG_URL = 'https://identitytoolkit.googleapis.com/v2beta1' + PROVIDER_CONFIG_URL = 'https://identitytoolkit.googleapis.com/v2' - def __init__(self, http_client, project_id, tenant_id=None): + def __init__(self, http_client, project_id, tenant_id=None, url_override=None): self.http_client = http_client - self.base_url = '{0}/projects/{1}'.format(self.PROVIDER_CONFIG_URL, project_id) + url_prefix = url_override or self.PROVIDER_CONFIG_URL + self.base_url = f'{url_prefix}/projects/{project_id}' if tenant_id: - self.base_url += '/tenants/{0}'.format(tenant_id) + self.base_url += f'/tenants/{tenant_id}' def get_oidc_provider_config(self, provider_id): _validate_oidc_provider_id(provider_id) - body = self._make_request('get', '/oauthIdpConfigs/{0}'.format(provider_id)) + body = self._make_request('get', f'/oauthIdpConfigs/{provider_id}') return OIDCProviderConfig(body) def create_oidc_provider_config( - self, provider_id, client_id, issuer, display_name=None, enabled=None): + self, provider_id, client_id, issuer, display_name=None, enabled=None, + client_secret=None, id_token_response_type=None, code_response_type=None): """Creates a new OIDC provider config from the given parameters.""" _validate_oidc_provider_id(provider_id) req = { @@ -190,12 +204,28 @@ def create_oidc_provider_config( if enabled is not None: req['enabled'] = _auth_utils.validate_boolean(enabled, 'enabled') - params = 'oauthIdpConfigId={0}'.format(provider_id) + response_type = {} + if id_token_response_type is False and code_response_type is False: + raise ValueError('At least one response type must be returned.') + if id_token_response_type is not None: + response_type['idToken'] = _auth_utils.validate_boolean( + id_token_response_type, 'id_token_response_type') + if code_response_type is not None: + response_type['code'] = _auth_utils.validate_boolean( + code_response_type, 'code_response_type') + if code_response_type: + req['clientSecret'] = _validate_non_empty_string(client_secret, 'client_secret') + if response_type: + req['responseType'] = response_type + + params = f'oauthIdpConfigId={provider_id}' body = self._make_request('post', '/oauthIdpConfigs', json=req, params=params) return OIDCProviderConfig(body) def update_oidc_provider_config( - self, provider_id, client_id=None, issuer=None, display_name=None, enabled=None): + self, provider_id, client_id=None, issuer=None, display_name=None, + enabled=None, client_secret=None, id_token_response_type=None, + code_response_type=None): """Updates an existing OIDC provider config with the given parameters.""" _validate_oidc_provider_id(provider_id) req = {} @@ -211,18 +241,32 @@ def update_oidc_provider_config( if issuer: req['issuer'] = _validate_url(issuer, 'issuer') + response_type = {} + if id_token_response_type is False and code_response_type is False: + raise ValueError('At least one response type must be returned.') + if id_token_response_type is not None: + response_type['idToken'] = _auth_utils.validate_boolean( + id_token_response_type, 'id_token_response_type') + if code_response_type is not None: + response_type['code'] = _auth_utils.validate_boolean( + code_response_type, 'code_response_type') + if code_response_type: + req['clientSecret'] = _validate_non_empty_string(client_secret, 'client_secret') + if response_type: + req['responseType'] = response_type + if not req: raise ValueError('At least one parameter must be specified for update.') update_mask = _auth_utils.build_update_mask(req) - params = 'updateMask={0}'.format(','.join(update_mask)) - url = '/oauthIdpConfigs/{0}'.format(provider_id) + params = f'updateMask={",".join(update_mask)}' + url = f'/oauthIdpConfigs/{provider_id}' body = self._make_request('patch', url, json=req, params=params) return OIDCProviderConfig(body) def delete_oidc_provider_config(self, provider_id): _validate_oidc_provider_id(provider_id) - self._make_request('delete', '/oauthIdpConfigs/{0}'.format(provider_id)) + self._make_request('delete', f'/oauthIdpConfigs/{provider_id}') def list_oidc_provider_configs(self, page_token=None, max_results=MAX_LIST_CONFIGS_RESULTS): return _ListOIDCProviderConfigsPage( @@ -233,7 +277,7 @@ def _fetch_oidc_provider_configs(self, page_token=None, max_results=MAX_LIST_CON def get_saml_provider_config(self, provider_id): _validate_saml_provider_id(provider_id) - body = self._make_request('get', '/inboundSamlConfigs/{0}'.format(provider_id)) + body = self._make_request('get', f'/inboundSamlConfigs/{provider_id}') return SAMLProviderConfig(body) def create_saml_provider_config( @@ -257,7 +301,7 @@ def create_saml_provider_config( if enabled is not None: req['enabled'] = _auth_utils.validate_boolean(enabled, 'enabled') - params = 'inboundSamlConfigId={0}'.format(provider_id) + params = f'inboundSamlConfigId={provider_id}' body = self._make_request('post', '/inboundSamlConfigs', json=req, params=params) return SAMLProviderConfig(body) @@ -297,14 +341,14 @@ def update_saml_provider_config( raise ValueError('At least one parameter must be specified for update.') update_mask = _auth_utils.build_update_mask(req) - params = 'updateMask={0}'.format(','.join(update_mask)) - url = '/inboundSamlConfigs/{0}'.format(provider_id) + params = f'updateMask={",".join(update_mask)}' + url = f'/inboundSamlConfigs/{provider_id}' body = self._make_request('patch', url, json=req, params=params) return SAMLProviderConfig(body) def delete_saml_provider_config(self, provider_id): _validate_saml_provider_id(provider_id) - self._make_request('delete', '/inboundSamlConfigs/{0}'.format(provider_id)) + self._make_request('delete', f'/inboundSamlConfigs/{provider_id}') def list_saml_provider_configs(self, page_token=None, max_results=MAX_LIST_CONFIGS_RESULTS): return _ListSAMLProviderConfigsPage( @@ -323,15 +367,15 @@ def _fetch_provider_configs(self, path, page_token=None, max_results=MAX_LIST_CO if max_results < 1 or max_results > MAX_LIST_CONFIGS_RESULTS: raise ValueError( 'Max results must be a positive integer less than or equal to ' - '{0}.'.format(MAX_LIST_CONFIGS_RESULTS)) + f'{MAX_LIST_CONFIGS_RESULTS}.') - params = 'pageSize={0}'.format(max_results) + params = f'pageSize={max_results}' if page_token: - params += '&pageToken={0}'.format(page_token) + params += f'&pageToken={page_token}' return self._make_request('get', path, params=params) def _make_request(self, method, path, **kwargs): - url = '{0}{1}'.format(self.base_url, path) + url = f'{self.base_url}{path}' try: return self.http_client.body(method, url, **kwargs) except requests.exceptions.RequestException as error: @@ -341,29 +385,27 @@ def _make_request(self, method, path, **kwargs): def _validate_oidc_provider_id(provider_id): if not isinstance(provider_id, str): raise ValueError( - 'Invalid OIDC provider ID: {0}. Provider ID must be a non-empty string.'.format( - provider_id)) + f'Invalid OIDC provider ID: {provider_id}. Provider ID must be a non-empty string.') if not provider_id.startswith('oidc.'): - raise ValueError('Invalid OIDC provider ID: {0}.'.format(provider_id)) + raise ValueError(f'Invalid OIDC provider ID: {provider_id}.') return provider_id def _validate_saml_provider_id(provider_id): if not isinstance(provider_id, str): raise ValueError( - 'Invalid SAML provider ID: {0}. Provider ID must be a non-empty string.'.format( - provider_id)) + f'Invalid SAML provider ID: {provider_id}. Provider ID must be a non-empty string.') if not provider_id.startswith('saml.'): - raise ValueError('Invalid SAML provider ID: {0}.'.format(provider_id)) + raise ValueError(f'Invalid SAML provider ID: {provider_id}.') return provider_id def _validate_non_empty_string(value, label): """Validates that the given value is a non-empty string.""" if not isinstance(value, str): - raise ValueError('Invalid type for {0}: {1}.'.format(label, value)) + raise ValueError(f'Invalid type for {label}: {value}.') if not value: - raise ValueError('{0} must not be empty.'.format(label)) + raise ValueError(f'{label} must not be empty.') return value @@ -371,20 +413,19 @@ def _validate_url(url, label): """Validates that the given value is a well-formed URL string.""" if not isinstance(url, str) or not url: raise ValueError( - 'Invalid photo URL: "{0}". {1} must be a non-empty ' - 'string.'.format(url, label)) + f'Invalid photo URL: "{url}". {label} must be a non-empty string.') try: parsed = parse.urlparse(url) if not parsed.netloc: - raise ValueError('Malformed {0}: "{1}".'.format(label, url)) + raise ValueError(f'Malformed {label}: "{url}".') return url - except Exception: - raise ValueError('Malformed {0}: "{1}".'.format(label, url)) + except Exception as exception: + raise ValueError(f'Malformed {label}: "{url}".') from exception def _validate_x509_certificates(x509_certificates): if not isinstance(x509_certificates, list) or not x509_certificates: raise ValueError('x509_certificates must be a non-empty list.') - if not all([isinstance(cert, str) and cert for cert in x509_certificates]): + if not all(isinstance(cert, str) and cert for cert in x509_certificates): raise ValueError('x509_certificates must only contain non-empty strings.') return [{'x509Certificate': cert} for cert in x509_certificates] diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index 2226675f9..8f3c419a7 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -15,6 +15,7 @@ """Firebase auth utils.""" import json +import os import re from urllib import parse @@ -22,6 +23,7 @@ from firebase_admin import _utils +EMULATOR_HOST_ENV_VAR = 'FIREBASE_AUTH_EMULATOR_HOST' MAX_CLAIMS_PAYLOAD_SIZE = 1000 RESERVED_CLAIMS = set([ 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat', @@ -41,29 +43,44 @@ class PageIterator: def __init__(self, current_page): if not current_page: raise ValueError('Current page must not be None.') + self._current_page = current_page - self._index = 0 + self._iter = None + + def __next__(self): + if self._iter is None: + self._iter = iter(self.items) - def next(self): - if self._index == len(self.items): + try: + return next(self._iter) + except StopIteration: if self._current_page.has_next_page: self._current_page = self._current_page.get_next_page() - self._index = 0 - if self._index < len(self.items): - result = self.items[self._index] - self._index += 1 - return result - raise StopIteration + self._iter = iter(self.items) + + return next(self._iter) + + raise + + def __iter__(self): + return self @property def items(self): raise NotImplementedError - def __next__(self): - return self.next() - def __iter__(self): - return self +def get_emulator_host(): + emulator_host = os.getenv(EMULATOR_HOST_ENV_VAR, '') + if emulator_host and '//' in emulator_host: + raise ValueError( + f'Invalid {EMULATOR_HOST_ENV_VAR}: "{emulator_host}". ' + 'It must follow format "host:port".') + return emulator_host + + +def is_emulated(): + return get_emulator_host() != '' def validate_uid(uid, required=False): @@ -71,8 +88,8 @@ def validate_uid(uid, required=False): return None if not isinstance(uid, str) or not uid or len(uid) > 128: raise ValueError( - 'Invalid uid: "{0}". The uid must be a non-empty string with no more than 128 ' - 'characters.'.format(uid)) + f'Invalid uid: "{uid}". The uid must be a non-empty string with no more than 128 ' + 'characters.') return uid def validate_email(email, required=False): @@ -80,10 +97,10 @@ def validate_email(email, required=False): return None if not isinstance(email, str) or not email: raise ValueError( - 'Invalid email: "{0}". Email must be a non-empty string.'.format(email)) + f'Invalid email: "{email}". Email must be a non-empty string.') parts = email.split('@') if len(parts) != 2 or not parts[0] or not parts[1]: - raise ValueError('Malformed email address string: "{0}".'.format(email)) + raise ValueError(f'Malformed email address string: "{email}".') return email def validate_phone(phone, required=False): @@ -96,11 +113,12 @@ def validate_phone(phone, required=False): if phone is None and not required: return None if not isinstance(phone, str) or not phone: - raise ValueError('Invalid phone number: "{0}". Phone number must be a non-empty ' - 'string.'.format(phone)) + raise ValueError( + f'Invalid phone number: "{phone}". Phone number must be a non-empty string.') if not phone.startswith('+') or not re.search('[a-zA-Z0-9]', phone): - raise ValueError('Invalid phone number: "{0}". Phone number must be a valid, E.164 ' - 'compliant identifier.'.format(phone)) + raise ValueError( + f'Invalid phone number: "{phone}". Phone number must be a valid, E.164 ' + 'compliant identifier.') return phone def validate_password(password, required=False): @@ -115,7 +133,7 @@ def validate_bytes(value, label, required=False): if value is None and not required: return None if not isinstance(value, bytes) or not value: - raise ValueError('{0} must be a non-empty byte sequence.'.format(label)) + raise ValueError(f'{label} must be a non-empty byte sequence.') return value def validate_display_name(display_name, required=False): @@ -123,8 +141,8 @@ def validate_display_name(display_name, required=False): return None if not isinstance(display_name, str) or not display_name: raise ValueError( - 'Invalid display name: "{0}". Display name must be a non-empty ' - 'string.'.format(display_name)) + f'Invalid display name: "{display_name}". Display name must be a non-empty ' + 'string.') return display_name def validate_provider_id(provider_id, required=True): @@ -132,8 +150,7 @@ def validate_provider_id(provider_id, required=True): return None if not isinstance(provider_id, str) or not provider_id: raise ValueError( - 'Invalid provider ID: "{0}". Provider ID must be a non-empty ' - 'string.'.format(provider_id)) + f'Invalid provider ID: "{provider_id}". Provider ID must be a non-empty string.') return provider_id def validate_provider_uid(provider_uid, required=True): @@ -141,8 +158,7 @@ def validate_provider_uid(provider_uid, required=True): return None if not isinstance(provider_uid, str) or not provider_uid: raise ValueError( - 'Invalid provider UID: "{0}". Provider UID must be a non-empty ' - 'string.'.format(provider_uid)) + f'Invalid provider UID: "{provider_uid}". Provider UID must be a non-empty string.') return provider_uid def validate_photo_url(photo_url, required=False): @@ -151,15 +167,14 @@ def validate_photo_url(photo_url, required=False): return None if not isinstance(photo_url, str) or not photo_url: raise ValueError( - 'Invalid photo URL: "{0}". Photo URL must be a non-empty ' - 'string.'.format(photo_url)) + f'Invalid photo URL: "{photo_url}". Photo URL must be a non-empty string.') try: parsed = parse.urlparse(photo_url) if not parsed.netloc: - raise ValueError('Malformed photo URL: "{0}".'.format(photo_url)) + raise ValueError(f'Malformed photo URL: "{photo_url}".') return photo_url - except Exception: - raise ValueError('Malformed photo URL: "{0}".'.format(photo_url)) + except Exception as err: + raise ValueError(f'Malformed photo URL: "{photo_url}".') from err def validate_timestamp(timestamp, label, required=False): """Validates the given timestamp value. Timestamps must be positive integers.""" @@ -169,14 +184,13 @@ def validate_timestamp(timestamp, label, required=False): raise ValueError('Boolean value specified as timestamp.') try: timestamp_int = int(timestamp) - except TypeError: - raise ValueError('Invalid type for timestamp value: {0}.'.format(timestamp)) - else: - if timestamp_int != timestamp: - raise ValueError('{0} must be a numeric value and a whole number.'.format(label)) - if timestamp_int <= 0: - raise ValueError('{0} timestamp must be a positive interger.'.format(label)) - return timestamp_int + except TypeError as err: + raise ValueError(f'Invalid type for timestamp value: {timestamp}.') from err + if timestamp_int != timestamp: + raise ValueError(f'{label} must be a numeric value and a whole number.') + if timestamp_int <= 0: + raise ValueError(f'{label} timestamp must be a positive interger.') + return timestamp_int def validate_int(value, label, low=None, high=None): """Validates that the given value represents an integer. @@ -187,31 +201,30 @@ def validate_int(value, label, low=None, high=None): a developer error. """ if value is None or isinstance(value, bool): - raise ValueError('Invalid type for integer value: {0}.'.format(value)) + raise ValueError(f'Invalid type for integer value: {value}.') try: val_int = int(value) - except TypeError: - raise ValueError('Invalid type for integer value: {0}.'.format(value)) - else: - if val_int != value: - # This will be True for non-numeric values like '2' and non-whole numbers like 2.5. - raise ValueError('{0} must be a numeric value and a whole number.'.format(label)) - if low is not None and val_int < low: - raise ValueError('{0} must not be smaller than {1}.'.format(label, low)) - if high is not None and val_int > high: - raise ValueError('{0} must not be larger than {1}.'.format(label, high)) - return val_int + except TypeError as err: + raise ValueError(f'Invalid type for integer value: {value}.') from err + if val_int != value: + # This will be True for non-numeric values like '2' and non-whole numbers like 2.5. + raise ValueError(f'{label} must be a numeric value and a whole number.') + if low is not None and val_int < low: + raise ValueError(f'{label} must not be smaller than {low}.') + if high is not None and val_int > high: + raise ValueError(f'{label} must not be larger than {high}.') + return val_int def validate_string(value, label): """Validates that the given value is a string.""" if not isinstance(value, str): - raise ValueError('Invalid type for {0}: {1}.'.format(label, value)) + raise ValueError(f'Invalid type for {label}: {value}.') return value def validate_boolean(value, label): """Validates that the given value is a boolean.""" if not isinstance(value, bool): - raise ValueError('Invalid type for {0}: {1}.'.format(label, value)) + raise ValueError(f'Invalid type for {label}: {value}.') return value def validate_custom_claims(custom_claims, required=False): @@ -225,30 +238,39 @@ def validate_custom_claims(custom_claims, required=False): claims_str = str(custom_claims) if len(claims_str) > MAX_CLAIMS_PAYLOAD_SIZE: raise ValueError( - 'Custom claims payload must not exceed {0} characters.'.format( - MAX_CLAIMS_PAYLOAD_SIZE)) + f'Custom claims payload must not exceed {MAX_CLAIMS_PAYLOAD_SIZE} characters.') try: parsed = json.loads(claims_str) - except Exception: - raise ValueError('Failed to parse custom claims string as JSON.') + except Exception as err: + raise ValueError('Failed to parse custom claims string as JSON.') from err if not isinstance(parsed, dict): raise ValueError('Custom claims must be parseable as a JSON object.') invalid_claims = RESERVED_CLAIMS.intersection(set(parsed.keys())) if len(invalid_claims) > 1: joined = ', '.join(sorted(invalid_claims)) - raise ValueError('Claims "{0}" are reserved, and must not be set.'.format(joined)) + raise ValueError(f'Claims "{joined}" are reserved, and must not be set.') if len(invalid_claims) == 1: raise ValueError( - 'Claim "{0}" is reserved, and must not be set.'.format(invalid_claims.pop())) + f'Claim "{invalid_claims.pop()}" is reserved, and must not be set.') return claims_str def validate_action_type(action_type): if action_type not in VALID_EMAIL_ACTION_TYPES: - raise ValueError('Invalid action type provided action_type: {0}. \ - Valid values are {1}'.format(action_type, ', '.join(VALID_EMAIL_ACTION_TYPES))) + raise ValueError( + f'Invalid action type provided action_type: {action_type}. Valid values are ' + f'{", ".join(VALID_EMAIL_ACTION_TYPES)}') return action_type +def validate_provider_ids(provider_ids, required=False): + if not provider_ids: + if required: + raise ValueError('Invalid provider IDs. Provider ids should be provided') + return [] + for provider_id in provider_ids: + validate_provider_id(provider_id, True) + return provider_ids + def build_update_mask(params): """Creates an update mask list from the given dictionary.""" mask = [] @@ -256,7 +278,7 @@ def build_update_mask(params): if isinstance(value, dict): child_mask = build_update_mask(value) for child in child_mask: - mask.append('{0}.{1}'.format(key, child)) + mask.append(f'{key}.{child}') else: mask.append(key) @@ -302,6 +324,17 @@ def __init__(self, message, cause, http_response): exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) +class InvalidHostingLinkDomainError(exceptions.InvalidArgumentError): + """The provided hosting link domain is not configured in Firebase Hosting + or is not owned by the current project.""" + + default_message = ('The provided hosting link domain is not configured in Firebase ' + 'Hosting or is not owned by the current project') + + def __init__(self, message, cause, http_response): + exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) + + class InvalidIdTokenError(exceptions.InvalidArgumentError): """The provided ID token is not a valid Firebase ID token.""" @@ -336,6 +369,15 @@ def __init__(self, message, cause=None, http_response=None): exceptions.NotFoundError.__init__(self, message, cause, http_response) +class EmailNotFoundError(exceptions.NotFoundError): + """No user record found for the specified email.""" + + default_message = 'No user record found for the given email' + + def __init__(self, message, cause=None, http_response=None): + exceptions.NotFoundError.__init__(self, message, cause, http_response) + + class TenantNotFoundError(exceptions.NotFoundError): """No tenant found for the specified identifier.""" @@ -361,17 +403,44 @@ def __init__(self, message, cause=None, http_response=None): exceptions.NotFoundError.__init__(self, message, cause, http_response) +class UserDisabledError(exceptions.InvalidArgumentError): + """An operation failed due to a user record being disabled.""" + + default_message = 'The user record is disabled' + + def __init__(self, message, cause=None, http_response=None): + exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) + + +class TooManyAttemptsTryLaterError(exceptions.ResourceExhaustedError): + """Rate limited because of too many attempts.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.ResourceExhaustedError.__init__(self, message, cause, http_response) + + +class ResetPasswordExceedLimitError(exceptions.ResourceExhaustedError): + """Reset password emails exceeded their limits.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.ResourceExhaustedError.__init__(self, message, cause, http_response) + + _CODE_TO_EXC_TYPE = { 'CONFIGURATION_NOT_FOUND': ConfigurationNotFoundError, 'DUPLICATE_EMAIL': EmailAlreadyExistsError, 'DUPLICATE_LOCAL_ID': UidAlreadyExistsError, 'EMAIL_EXISTS': EmailAlreadyExistsError, + 'EMAIL_NOT_FOUND': EmailNotFoundError, 'INSUFFICIENT_PERMISSION': InsufficientPermissionError, 'INVALID_DYNAMIC_LINK_DOMAIN': InvalidDynamicLinkDomainError, + 'INVALID_HOSTING_LINK_DOMAIN': InvalidHostingLinkDomainError, 'INVALID_ID_TOKEN': InvalidIdTokenError, 'PHONE_NUMBER_EXISTS': PhoneNumberAlreadyExistsError, 'TENANT_NOT_FOUND': TenantNotFoundError, 'USER_NOT_FOUND': UserNotFoundError, + 'TOO_MANY_ATTEMPTS_TRY_LATER': TooManyAttemptsTryLaterError, + 'RESET_PASSWORD_EXCEED_LIMIT': ResetPasswordExceedLimitError, } @@ -382,7 +451,7 @@ def handle_auth_backend_error(error): code, custom_message = _parse_error_body(error.response) if not code: - msg = 'Unexpected error response: {0}'.format(error.response.content.decode()) + msg = f'Unexpected error response: {error.response.content.decode()}' return _utils.handle_requests_error(error, message=msg) exc_type = _CODE_TO_EXC_TYPE.get(code) @@ -410,7 +479,7 @@ def _parse_error_body(response): separator = code.find(':') if separator != -1: custom_message = code[separator + 1:].strip() - code = code[:separator] + code = code[:separator].strip() return code, custom_message @@ -418,5 +487,5 @@ def _parse_error_body(response): def _build_error_message(code, exc_type, custom_message): default_message = exc_type.default_message if ( exc_type and hasattr(exc_type, 'default_message')) else 'Error while calling Auth service' - ext = ' {0}'.format(custom_message) if custom_message else '' - return '{0} ({1}).{2}'.format(default_message, code, ext) + ext = f' {custom_message}' if custom_message else '' + return f'{default_message} ({code}).{ext}' diff --git a/firebase_admin/_http_client.py b/firebase_admin/_http_client.py index f6f0d89fa..6d2582291 100644 --- a/firebase_admin/_http_client.py +++ b/firebase_admin/_http_client.py @@ -14,26 +14,44 @@ """Internal HTTP client module. - This module provides utilities for making HTTP calls using the requests library. - """ - -from google.auth import transport -import requests +This module provides utilities for making HTTP calls using the requests library. +""" + +from __future__ import annotations +import logging +from typing import Any, Dict, Generator, Optional, Tuple, Union +import httpx +import requests.adapters from requests.packages.urllib3.util import retry # pylint: disable=import-error +from google.auth import credentials +from google.auth import transport +from google.auth.transport import requests as google_auth_requests +from firebase_admin import _utils +from firebase_admin._retry import HttpxRetry, HttpxRetryTransport -_ANY_METHOD = None +logger = logging.getLogger(__name__) +if hasattr(retry.Retry.DEFAULT, 'allowed_methods'): + _ANY_METHOD = {'allowed_methods': None} +else: + _ANY_METHOD = {'method_whitelist': None} # Default retry configuration: Retries once on low-level connection and socket read errors. # Retries up to 4 times on HTTP 500 and 503 errors, with exponential backoff. Returns the # last response upon exhausting all retries. DEFAULT_RETRY_CONFIG = retry.Retry( - connect=1, read=1, status=4, status_forcelist=[500, 503], method_whitelist=_ANY_METHOD, - raise_on_status=False, backoff_factor=0.5) + connect=1, read=1, status=4, status_forcelist=[500, 503], + raise_on_status=False, backoff_factor=0.5, **_ANY_METHOD) + +DEFAULT_HTTPX_RETRY_CONFIG = HttpxRetry( + max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5) DEFAULT_TIMEOUT_SECONDS = 120 +METRICS_HEADERS = { + 'x-goog-api-client': _utils.get_metrics_header(), +} class HttpClient: """Base HTTP client used to make HTTP calls. @@ -102,7 +120,7 @@ class call this method to send HTTP requests out. Refer to Args: method: HTTP method name as a string (e.g. get, post). url: URL of the remote endpoint. - kwargs: An additional set of keyword arguments to be passed into the requests API + **kwargs: An additional set of keyword arguments to be passed into the requests API (e.g. json, params, timeout). Returns: @@ -113,6 +131,7 @@ class call this method to send HTTP requests out. Refer to """ if 'timeout' not in kwargs: kwargs['timeout'] = self.timeout + kwargs.setdefault('headers', {}).update(METRICS_HEADERS) resp = self._session.request(method, self.base_url + url, **kwargs) resp.raise_for_status() return resp @@ -137,7 +156,6 @@ def close(self): self._session.close() self._session = None - class JsonHttpClient(HttpClient): """An HTTP client that parses response messages as JSON.""" @@ -146,3 +164,194 @@ def __init__(self, **kwargs): def parse_body(self, resp): return resp.json() + +class GoogleAuthCredentialFlow(httpx.Auth): + """Google Auth Credential Auth Flow""" + def __init__(self, credential: credentials.Credentials): + self._credential = credential + self._max_refresh_attempts = 2 + self._refresh_status_codes = (401,) + + def apply_auth_headers( + self, + request: httpx.Request, + auth_request: google_auth_requests.Request + ) -> None: + """A helper function that refreshes credentials if needed and mutates the request headers + to contain access token and any other Google Auth headers.""" + + logger.debug( + 'Attempting to apply auth headers. Credential validity before: %s', + self._credential.valid + ) + self._credential.before_request( + auth_request, request.method, str(request.url), request.headers + ) + logger.debug('Auth headers applied. Credential validity after: %s', self._credential.valid) + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + _original_headers = request.headers.copy() + _credential_refresh_attempt = 0 + + # Create a Google auth request object to be used for refreshing credentials + auth_request = google_auth_requests.Request() + + while True: + # Copy original headers for each attempt + request.headers = _original_headers.copy() + + # Apply auth headers (which might include an implicit refresh if token is expired) + self.apply_auth_headers(request, auth_request) + + logger.debug( + 'Dispatching request, attempt %d of %d', + _credential_refresh_attempt, self._max_refresh_attempts + ) + response: httpx.Response = yield request + + if response.status_code in self._refresh_status_codes: + if _credential_refresh_attempt < self._max_refresh_attempts: + logger.debug( + 'Received status %d. Attempting explicit credential refresh. \ + Attempt %d of %d.', + response.status_code, + _credential_refresh_attempt + 1, + self._max_refresh_attempts + ) + # Explicitly force a credentials refresh + self._credential.refresh(auth_request) + _credential_refresh_attempt += 1 + else: + logger.debug( + 'Received status %d, but max auth refresh attempts (%d) reached. \ + Returning last response.', + response.status_code, self._max_refresh_attempts + ) + break + else: + # Status code is not one that requires a refresh, so break and return response + logger.debug( + 'Status code %d does not require refresh. Returning response.', + response.status_code + ) + break + # The last yielded response is automatically returned by httpx's auth flow. + +class HttpxAsyncClient(): + """Async HTTP client used to make HTTP/2 calls using HTTPX. + + HttpxAsyncClient maintains an async HTTPX client, handles request authentication, and retries + if necessary. + """ + def __init__( + self, + credential: Optional[credentials.Credentials] = None, + base_url: str = '', + headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None, + retry_config: HttpxRetry = DEFAULT_HTTPX_RETRY_CONFIG, + timeout: int = DEFAULT_TIMEOUT_SECONDS, + http2: bool = True + ) -> None: + """Creates a new HttpxAsyncClient instance from the provided arguments. + + If a credential is provided, initializes a new async HTTPX client authorized with it. + Otherwise, initializes a new unauthorized async HTTPX client. + + Args: + credential: A Google credential that can be used to authenticate requests (optional). + base_url: A URL prefix to be added to all outgoing requests (optional). + headers: A map of headers to be added to all outgoing requests (optional). + retry_config: A HttpxRetry configuration. Default settings would retry up to 4 times for + HTTP 500 and 503 errors (optional). + timeout: HTTP timeout in seconds. Defaults to 120 seconds when not specified (optional). + http2: A boolean indicating if HTTP/2 support should be enabled. Defaults to `True` when + not specified (optional). + """ + self._base_url = base_url + self._timeout = timeout + self._headers = {**headers, **METRICS_HEADERS} if headers else {**METRICS_HEADERS} + self._retry_config = retry_config + + # Only set up retries on urls starting with 'http://' and 'https://' + self._mounts = { + 'http://': HttpxRetryTransport(retry=self._retry_config, http2=http2), + 'https://': HttpxRetryTransport(retry=self._retry_config, http2=http2) + } + + if credential: + self._async_client = httpx.AsyncClient( + http2=http2, + timeout=self._timeout, + headers=self._headers, + auth=GoogleAuthCredentialFlow(credential), # Add auth flow for credentials. + mounts=self._mounts + ) + else: + self._async_client = httpx.AsyncClient( + http2=http2, + timeout=self._timeout, + headers=self._headers, + mounts=self._mounts + ) + + @property + def base_url(self): + return self._base_url + + @property + def timeout(self): + return self._timeout + + @property + def async_client(self): + return self._async_client + + async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + """Makes an HTTP call using the HTTPX library. + + This is the sole entry point to the HTTPX library. All other helper methods in this + class call this method to send HTTP requests out. Refer to + https://www.python-httpx.org/api/ for more information on supported options + and features. + + Args: + method: HTTP method name as a string (e.g. get, post). + url: URL of the remote endpoint. + **kwargs: An additional set of keyword arguments to be passed into the HTTPX API + (e.g. json, params, timeout). + + Returns: + Response: An HTTPX response object. + + Raises: + HTTPError: Any HTTPX exceptions encountered while making the HTTP call. + RequestException: Any requests exceptions encountered while making the HTTP call. + """ + if 'timeout' not in kwargs: + kwargs['timeout'] = self.timeout + resp = await self._async_client.request(method, self.base_url + url, **kwargs) + return resp.raise_for_status() + + async def headers(self, method: str, url: str, **kwargs: Any) -> httpx.Headers: + resp = await self.request(method, url, **kwargs) + return resp.headers + + async def body_and_response( + self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx.Response]: + resp = await self.request(method, url, **kwargs) + return self.parse_body(resp), resp + + async def body(self, method: str, url: str, **kwargs: Any) -> Any: + resp = await self.request(method, url, **kwargs) + return self.parse_body(resp) + + async def headers_and_body( + self, method: str, url: str, **kwargs: Any) -> Tuple[httpx.Headers, Any]: + resp = await self.request(method, url, **kwargs) + return resp.headers, self.parse_body(resp) + + def parse_body(self, resp: httpx.Response) -> Any: + return resp.json() + + async def aclose(self) -> None: + await self._async_client.aclose() diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 48a3dd3cd..4c0c6daa4 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -20,7 +20,7 @@ import numbers import re -import firebase_admin._messaging_utils as _messaging_utils +from firebase_admin import _messaging_utils class Message: @@ -99,10 +99,10 @@ def check_string(cls, label, value, non_empty=False): return None if not isinstance(value, str): if non_empty: - raise ValueError('{0} must be a non-empty string.'.format(label)) - raise ValueError('{0} must be a string.'.format(label)) + raise ValueError(f'{label} must be a non-empty string.') + raise ValueError(f'{label} must be a string.') if non_empty and not value: - raise ValueError('{0} must be a non-empty string.'.format(label)) + raise ValueError(f'{label} must be a non-empty string.') return value @classmethod @@ -110,7 +110,7 @@ def check_number(cls, label, value): if value is None: return None if not isinstance(value, numbers.Number): - raise ValueError('{0} must be a number.'.format(label)) + raise ValueError(f'{label} must be a number.') return value @classmethod @@ -119,13 +119,13 @@ def check_string_dict(cls, label, value): if value is None or value == {}: return None if not isinstance(value, dict): - raise ValueError('{0} must be a dictionary.'.format(label)) + raise ValueError(f'{label} must be a dictionary.') non_str = [k for k in value if not isinstance(k, str)] if non_str: - raise ValueError('{0} must not contain non-string keys.'.format(label)) + raise ValueError(f'{label} must not contain non-string keys.') non_str = [v for v in value.values() if not isinstance(v, str)] if non_str: - raise ValueError('{0} must not contain non-string values.'.format(label)) + raise ValueError(f'{label} must not contain non-string values.') return value @classmethod @@ -134,10 +134,10 @@ def check_string_list(cls, label, value): if value is None or value == []: return None if not isinstance(value, list): - raise ValueError('{0} must be a list of strings.'.format(label)) + raise ValueError(f'{label} must be a list of strings.') non_str = [k for k in value if not isinstance(k, str)] if non_str: - raise ValueError('{0} must not contain non-string values.'.format(label)) + raise ValueError(f'{label} must not contain non-string values.') return value @classmethod @@ -146,10 +146,10 @@ def check_number_list(cls, label, value): if value is None or value == []: return None if not isinstance(value, list): - raise ValueError('{0} must be a list of numbers.'.format(label)) + raise ValueError(f'{label} must be a list of numbers.') non_number = [k for k in value if not isinstance(k, numbers.Number)] if non_number: - raise ValueError('{0} must not contain non-number values.'.format(label)) + raise ValueError(f'{label} must not contain non-number values.') return value @classmethod @@ -157,7 +157,16 @@ def check_analytics_label(cls, label, value): """Checks if the given value is a valid analytics label.""" value = _Validators.check_string(label, value) if value is not None and not re.match(r'^[a-zA-Z0-9-_.~%]{1,50}$', value): - raise ValueError('Malformed {}.'.format(label)) + raise ValueError(f'Malformed {label}.') + return value + + @classmethod + def check_boolean(cls, label, value): + """Checks if the given value is boolean.""" + if value is None: + return None + if not isinstance(value, bool): + raise ValueError(f'{label} must be a boolean.') return value @classmethod @@ -166,7 +175,7 @@ def check_datetime(cls, label, value): if value is None: return None if not isinstance(value, datetime.datetime): - raise ValueError('{0} must be a datetime.'.format(label)) + raise ValueError(f'{label} must be a datetime.') return value @@ -196,6 +205,12 @@ def encode_android(cls, android): 'AndroidConfig.restricted_package_name', android.restricted_package_name), 'ttl': cls.encode_ttl(android.ttl), 'fcm_options': cls.encode_android_fcm_options(android.fcm_options), + 'direct_boot_ok': _Validators.check_boolean( + 'AndroidConfig.direct_boot_ok', android.direct_boot_ok), + 'bandwidth_constrained_ok': _Validators.check_boolean( + 'AndroidConfig.bandwidth_constrained_ok', android.bandwidth_constrained_ok), + 'restricted_satellite_ok': _Validators.check_boolean( + 'AndroidConfig.restricted_satellite_ok', android.restricted_satellite_ok), } result = cls.remove_null_values(result) priority = result.get('priority') @@ -234,8 +249,8 @@ def encode_ttl(cls, ttl): seconds = int(math.floor(total_seconds)) nanos = int((total_seconds - seconds) * 1e9) if nanos: - return '{0}.{1}s'.format(seconds, str(nanos).zfill(9)) - return '{0}s'.format(seconds) + return f'{seconds}.{str(nanos).zfill(9)}s' + return f'{seconds}s' @classmethod def encode_milliseconds(cls, label, msec): @@ -245,16 +260,16 @@ def encode_milliseconds(cls, label, msec): if isinstance(msec, numbers.Number): msec = datetime.timedelta(milliseconds=msec) if not isinstance(msec, datetime.timedelta): - raise ValueError('{0} must be a duration in milliseconds or an instance of ' - 'datetime.timedelta.'.format(label)) + raise ValueError( + f'{label} must be a duration in milliseconds or an instance of datetime.timedelta.') total_seconds = msec.total_seconds() if total_seconds < 0: - raise ValueError('{0} must not be negative.'.format(label)) + raise ValueError(f'{label} must not be negative.') seconds = int(math.floor(total_seconds)) nanos = int((total_seconds - seconds) * 1e9) if nanos: - return '{0}.{1}s'.format(seconds, str(nanos).zfill(9)) - return '{0}s'.format(seconds) + return f'{seconds}.{str(nanos).zfill(9)}s' + return f'{seconds}s' @classmethod def encode_android_notification(cls, notification): @@ -308,7 +323,9 @@ def encode_android_notification(cls, notification): 'visibility': _Validators.check_string( 'AndroidNotification.visibility', notification.visibility, non_empty=True), 'notification_count': _Validators.check_number( - 'AndroidNotification.notification_count', notification.notification_count) + 'AndroidNotification.notification_count', notification.notification_count), + 'proxy': _Validators.check_string( + 'AndroidNotification.proxy', notification.proxy, non_empty=True) } result = cls.remove_null_values(result) color = result.get('color') @@ -352,6 +369,13 @@ def encode_android_notification(cls, notification): 'AndroidNotification.vibrate_timings_millis', msec) vibrate_timing_strings.append(formated_string) result['vibrate_timings'] = vibrate_timing_strings + + proxy = result.get('proxy') + if proxy: + if proxy not in ('allow', 'deny', 'if_priority_lowered'): + raise ValueError( + 'AndroidNotification.proxy must be "allow", "deny" or "if_priority_lowered".') + result['proxy'] = proxy.upper() return result @classmethod @@ -389,7 +413,7 @@ def encode_light_settings(cls, light_settings): raise ValueError( 'LightSettings.color must be in the form #RRGGBB or #RRGGBBAA.') if len(color) == 7: - color = (color+'FF') + color = color+'FF' rgba = [int(color[i:i + 2], 16) / 255.0 for i in (1, 3, 5, 7)] result['color'] = {'red': rgba[0], 'green': rgba[1], 'blue': rgba[2], 'alpha': rgba[3]} @@ -455,7 +479,7 @@ def encode_webpush_notification(cls, notification): for key, value in notification.custom_data.items(): if key in result: raise ValueError( - 'Multiple specifications for {0} in WebpushNotification.'.format(key)) + f'Multiple specifications for {key} in WebpushNotification.') result[key] = value return cls.remove_null_values(result) @@ -509,6 +533,8 @@ def encode_apns(cls, apns): 'APNSConfig.headers', apns.headers), 'payload': cls.encode_apns_payload(apns.payload), 'fcm_options': cls.encode_apns_fcm_options(apns.fcm_options), + 'live_activity_token': _Validators.check_string( + 'APNSConfig.live_activity_token', apns.live_activity_token), } return cls.remove_null_values(result) @@ -563,7 +589,7 @@ def encode_aps(cls, aps): for key, val in aps.custom_data.items(): _Validators.check_string('Aps.custom_data key', key) if key in result: - raise ValueError('Multiple specifications for {0} in Aps.'.format(key)) + raise ValueError(f'Multiple specifications for {key} in Aps.') result[key] = val return cls.remove_null_values(result) @@ -676,7 +702,7 @@ def default(self, o): # pylint: disable=method-hidden } result['topic'] = MessageEncoder.sanitize_topic_name(result.get('topic')) result = MessageEncoder.remove_null_values(result) - target_count = sum([t in result for t in ['token', 'topic', 'condition']]) + target_count = sum(t in result for t in ['token', 'topic', 'condition']) if target_count != 1: raise ValueError('Exactly one of token, topic or condition must be specified.') return result diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index d25ba5520..773ed6057 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -13,6 +13,9 @@ # limitations under the License. """Types and utilities used by the messaging (FCM) module.""" +from __future__ import annotations +import datetime +from typing import Dict, Optional, Union from firebase_admin import exceptions @@ -49,10 +52,27 @@ class AndroidConfig: strings. When specified, overrides any data fields set via ``Message.data``. notification: A ``messaging.AndroidNotification`` to be included in the message (optional). fcm_options: A ``messaging.AndroidFCMOptions`` to be included in the message (optional). + direct_boot_ok: A boolean indicating whether messages will be allowed to be delivered to + the app while the device is in direct boot mode (optional). + bandwidth_constrained_ok: A boolean indicating whether messages will be allowed to be + delivered to the app while the device is on a bandwidth constrained network (optional). + restricted_satellite_ok: A boolean indicating whether messages will be allowed to be + delivered to the app while the device is on a restricted satellite network (optional). """ - def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_package_name=None, - data=None, notification=None, fcm_options=None): + def __init__( + self, + collapse_key: Optional[str] = None, + priority: Optional[str] = None, + ttl: Optional[Union[int, float, datetime.timedelta]] = None, + restricted_package_name: Optional[str] = None, + data: Optional[Dict[str, str]] = None, + notification: Optional[AndroidNotification] = None, + fcm_options: Optional[AndroidFCMOptions] = None, + direct_boot_ok: Optional[bool] = None, + bandwidth_constrained_ok: Optional[bool] = None, + restricted_satellite_ok: Optional[bool] = None + ): self.collapse_key = collapse_key self.priority = priority self.ttl = ttl @@ -60,6 +80,9 @@ def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_packag self.data = data self.notification = notification self.fcm_options = fcm_options + self.direct_boot_ok = direct_boot_ok + self.bandwidth_constrained_ok = bandwidth_constrained_ok + self.restricted_satellite_ok = restricted_satellite_ok class AndroidNotification: @@ -134,7 +157,8 @@ class AndroidNotification: If ``default_light_settings`` is set to ``True`` and ``light_settings`` is also set, the user-specified ``light_settings`` is used instead of the default value. visibility: Sets the visibility of the notification. Must be either ``private``, ``public``, - or ``secret``. If unspecified, default to ``private``. + or ``secret``. If unspecified, it remains undefined in the Admin SDK, and defers to + the FCM backend's default mapping. notification_count: Sets the number of items this notification represents. May be displayed as a badge count for Launchers that support badging. See ``NotificationBadge`` https://developer.android.com/training/notify-user/badges. For example, this might be @@ -142,6 +166,9 @@ class AndroidNotification: want the count here to represent the number of total new messages. If zero or unspecified, systems that support badging use the default, which is to increment a number displayed on the long-press menu each time a new notification arrives. + proxy: Sets if the notification may be proxied. Must be one of ``allow``, ``deny``, or + ``if_priority_lowered``. If unspecified, it remains undefined in the Admin SDK, and + defers to the FCM backend's default mapping. """ @@ -151,7 +178,8 @@ def __init__(self, title=None, body=None, icon=None, color=None, sound=None, tag title_loc_args=None, channel_id=None, image=None, ticker=None, sticky=None, event_timestamp=None, local_only=None, priority=None, vibrate_timings_millis=None, default_vibrate_timings=None, default_sound=None, light_settings=None, - default_light_settings=None, visibility=None, notification_count=None): + default_light_settings=None, visibility=None, notification_count=None, + proxy=None): self.title = title self.body = body self.icon = icon @@ -177,6 +205,7 @@ def __init__(self, title=None, body=None, icon=None, color=None, sound=None, tag self.default_light_settings = default_light_settings self.visibility = visibility self.notification_count = notification_count + self.proxy = proxy class LightSettings: @@ -325,15 +354,17 @@ class APNSConfig: payload: A ``messaging.APNSPayload`` to be included in the message (optional). fcm_options: A ``messaging.APNSFCMOptions`` instance to be included in the message (optional). + live_activity_token: A live activity token string (optional). .. _APNS Documentation: https://developer.apple.com/library/content/documentation\ /NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html """ - def __init__(self, headers=None, payload=None, fcm_options=None): + def __init__(self, headers=None, payload=None, fcm_options=None, live_activity_token=None): self.headers = headers self.payload = payload self.fcm_options = fcm_options + self.live_activity_token = live_activity_token class APNSPayload: @@ -341,7 +372,7 @@ class APNSPayload: Args: aps: A ``messaging.Aps`` instance to be included in the payload. - kwargs: Arbitrary keyword arguments to be included as custom fields in the payload + **kwargs: Arbitrary keyword arguments to be included as custom fields in the payload (optional). """ diff --git a/firebase_admin/_retry.py b/firebase_admin/_retry.py new file mode 100644 index 000000000..efd90a743 --- /dev/null +++ b/firebase_admin/_retry.py @@ -0,0 +1,223 @@ +# Copyright 2025 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Internal retry logic module + +This module provides utilities for adding retry logic to HTTPX requests +""" + +from __future__ import annotations +import copy +import email.utils +import random +import re +import time +from typing import Any, Callable, List, Optional, Tuple, Coroutine +import logging +import asyncio +import httpx + +logger = logging.getLogger(__name__) + + +class HttpxRetry: + """HTTPX based retry config""" + # Status codes to be used for respecting `Retry-After` header + RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503]) + + # Default maximum backoff time. + DEFAULT_BACKOFF_MAX = 120 + + def __init__( + self, + max_retries: int = 10, + status_forcelist: Optional[List[int]] = None, + backoff_factor: float = 0, + backoff_max: float = DEFAULT_BACKOFF_MAX, + backoff_jitter: float = 0, + history: Optional[List[Tuple[ + httpx.Request, + Optional[httpx.Response], + Optional[Exception] + ]]] = None, + respect_retry_after_header: bool = False, + ) -> None: + self.retries_left = max_retries + self.status_forcelist = status_forcelist + self.backoff_factor = backoff_factor + self.backoff_max = backoff_max + self.backoff_jitter = backoff_jitter + if history: + self.history = history + else: + self.history = [] + self.respect_retry_after_header = respect_retry_after_header + + def copy(self) -> HttpxRetry: + """Creates a deep copy of this instance.""" + return copy.deepcopy(self) + + def is_retryable_response(self, response: httpx.Response) -> bool: + """Determine if a response implies that the request should be retried if possible.""" + if self.status_forcelist and response.status_code in self.status_forcelist: + return True + + has_retry_after = bool(response.headers.get("Retry-After")) + if ( + self.respect_retry_after_header + and has_retry_after + and response.status_code in self.RETRY_AFTER_STATUS_CODES + ): + return True + + return False + + def is_exhausted(self) -> bool: + """Determine if there are anymore more retires.""" + # retries_left is negative + return self.retries_left < 0 + + # Identical implementation of `urllib3.Retry.parse_retry_after()` + def _parse_retry_after(self, retry_after_header: str) -> float | None: + """Parses Retry-After string into a float with unit seconds.""" + seconds: float + # Whitespace: https://tools.ietf.org/html/rfc7230#section-3.2.4 + if re.match(r"^\s*[0-9]+\s*$", retry_after_header): + seconds = int(retry_after_header) + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after_header) + if retry_date_tuple is None: + raise httpx.RemoteProtocolError(f"Invalid Retry-After header: {retry_after_header}") + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + seconds = max(seconds, 0) + + return seconds + + def get_retry_after(self, response: httpx.Response) -> float | None: + """Determine the Retry-After time needed before sending the next request.""" + retry_after_header = response.headers.get('Retry-After', None) + if retry_after_header: + # Convert retry header to a float in seconds + return self._parse_retry_after(retry_after_header) + return None + + def get_backoff_time(self): + """Determine the backoff time needed before sending the next request.""" + # attempt_count is the number of previous request attempts + attempt_count = len(self.history) + # Backoff should be set to 0 until after first retry. + if attempt_count <= 1: + return 0 + backoff = self.backoff_factor * (2 ** (attempt_count-1)) + if self.backoff_jitter: + backoff += random.random() * self.backoff_jitter + return float(max(0, min(self.backoff_max, backoff))) + + async def sleep_for_backoff(self) -> None: + """Determine and wait the backoff time needed before sending the next request.""" + backoff = self.get_backoff_time() + logger.debug('Sleeping for backoff of %f seconds following failed request', backoff) + await asyncio.sleep(backoff) + + async def sleep(self, response: httpx.Response) -> None: + """Determine and wait the time needed before sending the next request.""" + if self.respect_retry_after_header: + retry_after = self.get_retry_after(response) + if retry_after: + logger.debug( + 'Sleeping for Retry-After header of %f seconds following failed request', + retry_after + ) + await asyncio.sleep(retry_after) + return + await self.sleep_for_backoff() + + def increment( + self, + request: httpx.Request, + response: Optional[httpx.Response] = None, + error: Optional[Exception] = None + ) -> None: + """Update the retry state based on request attempt.""" + self.retries_left -= 1 + self.history.append((request, response, error)) + + +class HttpxRetryTransport(httpx.AsyncBaseTransport): + """HTTPX transport with retry logic.""" + + DEFAULT_RETRY = HttpxRetry(max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5) + + def __init__(self, retry: HttpxRetry = DEFAULT_RETRY, **kwargs: Any) -> None: + self._retry = retry + + transport_kwargs = kwargs.copy() + transport_kwargs.update({'retries': 0, 'http2': True}) + # We use a full AsyncHTTPTransport under the hood that is already + # set up to handle requests. We also insure that that transport's internal + # retries are not allowed. + self._wrapped_transport = httpx.AsyncHTTPTransport(**transport_kwargs) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return await self._dispatch_with_retry( + request, self._wrapped_transport.handle_async_request) + + async def _dispatch_with_retry( + self, + request: httpx.Request, + dispatch_method: Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]] + ) -> httpx.Response: + """Sends a request with retry logic using a provided dispatch method.""" + # This request config is used across all requests that use this transport and therefore + # needs to be copied to be used for just this request and it's retries. + retry = self._retry.copy() + # First request + response, error = None, None + + while not retry.is_exhausted(): + + # First retry + if response: + await retry.sleep(response) + + # Need to reset here so only last attempt's error or response is saved. + response, error = None, None + + try: + logger.debug('Sending request in _dispatch_with_retry(): %r', request) + response = await dispatch_method(request) + logger.debug('Received response: %r', response) + except httpx.HTTPError as err: + logger.debug('Received error: %r', err) + error = err + + if response and not retry.is_retryable_response(response): + return response + + if error: + raise error + + retry.increment(request, response, error) + + if response: + return response + if error: + raise error + raise AssertionError('_dispatch_with_retry() ended with no response or exception') + + async def aclose(self) -> None: + await self._wrapped_transport.aclose() diff --git a/firebase_admin/_rfc3339.py b/firebase_admin/_rfc3339.py index 2c720bdd1..8489bdcb9 100644 --- a/firebase_admin/_rfc3339.py +++ b/firebase_admin/_rfc3339.py @@ -84,4 +84,4 @@ def _parse_to_datetime(datestr): except ValueError: pass - raise ValueError('time data {0} does not match RFC3339 format'.format(datestr)) + raise ValueError(f'time data {datestr} does not match RFC3339 format') diff --git a/firebase_admin/_sseclient.py b/firebase_admin/_sseclient.py index 6585dfc80..3372fe5f2 100644 --- a/firebase_admin/_sseclient.py +++ b/firebase_admin/_sseclient.py @@ -34,7 +34,7 @@ class KeepAuthSession(transport.requests.AuthorizedSession): """A session that does not drop authentication on redirects between domains.""" def __init__(self, credential): - super(KeepAuthSession, self).__init__(credential) + super().__init__(credential) def rebuild_auth(self, prepared_request, response): pass @@ -86,7 +86,7 @@ def __init__(self, url, session, retry=3000, **kwargs): self.requests_kwargs = kwargs self.should_connect = True self.last_id = None - self.buf = u'' # Keep data here as it streams in + self.buf = '' # Keep data here as it streams in headers = self.requests_kwargs.get('headers', {}) # The SSE spec requires making requests with Cache-Control: no-cache @@ -153,9 +153,6 @@ def __next__(self): self.last_id = event.event_id return event - def next(self): - return self.__next__() - class Event: """Event represents the events fired by SSE.""" @@ -184,7 +181,7 @@ def parse(cls, raw): match = cls.sse_line_pattern.match(line) if match is None: # Malformed line. Discard but warn. - warnings.warn('Invalid SSE line: "%s"' % line, SyntaxWarning) + warnings.warn(f'Invalid SSE line: "{line}"', SyntaxWarning) continue name = match.groupdict()['name'] @@ -196,7 +193,7 @@ def parse(cls, raw): # If we already have some data, then join to it with a newline. # Else this is it. if event.data: - event.data = '%s\n%s' % (event.data, value) + event.data = f'{event.data}\n{value}' else: event.data = value elif name == 'event': diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index 18a8008c7..1607ef0ba 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -29,6 +29,7 @@ from firebase_admin import exceptions from firebase_admin import _auth_utils +from firebase_admin import _http_client # ID token constants @@ -52,14 +53,30 @@ ]) METADATA_SERVICE_URL = ('http://metadata.google.internal/computeMetadata/v1/instance/' 'service-accounts/default/email') +ALGORITHM_RS256 = 'RS256' +ALGORITHM_NONE = 'none' + +# Emulator fake account +AUTH_EMULATOR_EMAIL = 'firebase-auth-emulator@example.com' + + +class _EmulatedSigner(google.auth.crypt.Signer): + key_id = None + + def __init__(self): + pass + + def sign(self, message): + return b'' class _SigningProvider: """Stores a reference to a google.auth.crypto.Signer.""" - def __init__(self, signer, signer_email): + def __init__(self, signer, signer_email, alg=ALGORITHM_RS256): self._signer = signer self._signer_email = signer_email + self._alg = alg @property def signer(self): @@ -69,6 +86,10 @@ def signer(self): def signer_email(self): return self._signer_email + @property + def alg(self): + return self._alg + @classmethod def from_credential(cls, google_cred): return _SigningProvider(google_cred.signer, google_cred.signer_email) @@ -78,21 +99,28 @@ def from_iam(cls, request, google_cred, service_account): signer = iam.Signer(request, google_cred, service_account) return _SigningProvider(signer, service_account) + @classmethod + def for_emulator(cls): + return _SigningProvider(_EmulatedSigner(), AUTH_EMULATOR_EMAIL, ALGORITHM_NONE) + class TokenGenerator: """Generates custom tokens and session cookies.""" ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1' - def __init__(self, app, http_client): + def __init__(self, app, http_client, url_override=None): self.app = app self.http_client = http_client self.request = transport.requests.Request() - self.base_url = '{0}/projects/{1}'.format(self.ID_TOOLKIT_URL, app.project_id) + url_prefix = url_override or self.ID_TOOLKIT_URL + self.base_url = f'{url_prefix}/projects/{app.project_id}' self._signing_provider = None def _init_signing_provider(self): """Initializes a signing provider by following the go/firebase-admin-sign protocol.""" + if _auth_utils.is_emulated(): + return _SigningProvider.for_emulator() # If the SDK was initialized with a service account, use it to sign bytes. google_cred = self.app.credential.get_credential() if isinstance(google_cred, google.oauth2.service_account.Credentials): @@ -114,7 +142,7 @@ def _init_signing_provider(self): resp = self.request(url=METADATA_SERVICE_URL, headers={'Metadata-Flavor': 'Google'}) if resp.status != 200: raise ValueError( - 'Failed to contact the local metadata service: {0}.'.format(resp.data.decode())) + f'Failed to contact the local metadata service: {resp.data.decode()}.') service_account = resp.data.decode() return _SigningProvider.from_iam(self.request, google_cred, service_account) @@ -127,10 +155,10 @@ def signing_provider(self): except Exception as error: url = 'https://firebase.google.com/docs/auth/admin/create-custom-tokens' raise ValueError( - 'Failed to determine service account: {0}. Make sure to initialize the SDK ' - 'with service account credentials or specify a service account ID with ' - 'iam.serviceAccounts.signBlob permission. Please refer to {1} for more ' - 'details on creating custom tokens.'.format(error, url)) + f'Failed to determine service account: {error}. Make sure to initialize the ' + 'SDK with service account credentials or specify a service account ID with ' + f'iam.serviceAccounts.signBlob permission. Please refer to {url} for more ' + 'details on creating custom tokens.') from error return self._signing_provider def create_custom_token(self, uid, developer_claims=None, tenant_id=None): @@ -142,13 +170,13 @@ def create_custom_token(self, uid, developer_claims=None, tenant_id=None): disallowed_keys = set(developer_claims.keys()) & RESERVED_CLAIMS if disallowed_keys: if len(disallowed_keys) > 1: - error_message = ('Developer claims {0} are reserved and ' - 'cannot be specified.'.format( - ', '.join(disallowed_keys))) + error_message = ( + f'Developer claims {", ".join(disallowed_keys)} are reserved and cannot be ' + 'specified.') else: - error_message = ('Developer claim {0} is reserved and ' - 'cannot be specified.'.format( - ', '.join(disallowed_keys))) + error_message = ( + f'Developer claim {", ".join(disallowed_keys)} is reserved and cannot be ' + 'specified.') raise ValueError(error_message) if not uid or not isinstance(uid, str) or len(uid) > 128: @@ -169,11 +197,13 @@ def create_custom_token(self, uid, developer_claims=None, tenant_id=None): if developer_claims is not None: payload['claims'] = developer_claims + + header = {'alg': signing_provider.alg} try: - return jwt.encode(signing_provider.signer, payload) + return jwt.encode(signing_provider.signer, payload, header=header) except google.auth.exceptions.TransportError as error: - msg = 'Failed to sign custom token. {0}'.format(error) - raise TokenSignError(msg, error) + msg = f'Failed to sign custom token. {error}' + raise TokenSignError(msg, error) from error def create_session_cookie(self, id_token, expires_in): @@ -181,21 +211,22 @@ def create_session_cookie(self, id_token, expires_in): id_token = id_token.decode('utf-8') if isinstance(id_token, bytes) else id_token if not isinstance(id_token, str) or not id_token: raise ValueError( - 'Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) + f'Illegal ID token provided: {id_token}. ID token must be a non-empty string.') if isinstance(expires_in, datetime.timedelta): expires_in = int(expires_in.total_seconds()) if isinstance(expires_in, bool) or not isinstance(expires_in, int): - raise ValueError('Illegal expiry duration: {0}.'.format(expires_in)) + raise ValueError(f'Illegal expiry duration: {expires_in}.') if expires_in < MIN_SESSION_COOKIE_DURATION_SECONDS: - raise ValueError('Illegal expiry duration: {0}. Duration must be at least {1} ' - 'seconds.'.format(expires_in, MIN_SESSION_COOKIE_DURATION_SECONDS)) + raise ValueError( + f'Illegal expiry duration: {expires_in}. Duration must be at least ' + f'{MIN_SESSION_COOKIE_DURATION_SECONDS} seconds.') if expires_in > MAX_SESSION_COOKIE_DURATION_SECONDS: - raise ValueError('Illegal expiry duration: {0}. Duration must be at most {1} ' - 'seconds.'.format(expires_in, MAX_SESSION_COOKIE_DURATION_SECONDS)) + raise ValueError( + f'Illegal expiry duration: {expires_in}. Duration must be at most ' + f'{MAX_SESSION_COOKIE_DURATION_SECONDS} seconds.') - url = '{0}:createSessionCookie'.format(self.base_url) + url = f'{self.base_url}:createSessionCookie' payload = { 'idToken': id_token, 'validDuration': expires_in, @@ -204,19 +235,43 @@ def create_session_cookie(self, id_token, expires_in): body, http_resp = self.http_client.body_and_response('post', url, json=payload) except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) - else: - if not body or not body.get('sessionCookie'): - raise _auth_utils.UnexpectedResponseError( - 'Failed to create session cookie.', http_response=http_resp) - return body.get('sessionCookie') + if not body or not body.get('sessionCookie'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to create session cookie.', http_response=http_resp) + return body.get('sessionCookie') + + +class CertificateFetchRequest(transport.Request): + """A google-auth transport that supports HTTP cache-control. + + Also injects a timeout to each outgoing HTTP request. + """ + + def __init__(self, timeout_seconds=None): + self._session = cachecontrol.CacheControl(requests.Session()) + self._delegate = transport.requests.Request(self.session) + self._timeout_seconds = timeout_seconds + + @property + def session(self): + return self._session + + @property + def timeout_seconds(self): + return self._timeout_seconds + + def __call__(self, url, method='GET', body=None, headers=None, timeout=None, **kwargs): + timeout = timeout or self.timeout_seconds + return self._delegate( + url, method=method, body=body, headers=headers, timeout=timeout, **kwargs) class TokenVerifier: """Verifies ID tokens and session cookies.""" def __init__(self, app): - session = cachecontrol.CacheControl(requests.Session()) - self.request = transport.requests.Request(session=session) + timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) + self.request = CertificateFetchRequest(timeout) self.id_token_verifier = _JWTVerifier( project_id=app.project_id, short_name='ID token', operation='verify_id_token()', @@ -234,11 +289,11 @@ def __init__(self, app): invalid_token_error=InvalidSessionCookieError, expired_token_error=ExpiredSessionCookieError) - def verify_id_token(self, id_token): - return self.id_token_verifier.verify(id_token, self.request) + def verify_id_token(self, id_token, clock_skew_seconds=0): + return self.id_token_verifier.verify(id_token, self.request, clock_skew_seconds) - def verify_session_cookie(self, cookie): - return self.cookie_verifier.verify(cookie, self.request) + def verify_session_cookie(self, cookie, clock_skew_seconds=0): + return self.cookie_verifier.verify(cookie, self.request, clock_skew_seconds) class _JWTVerifier: @@ -252,26 +307,31 @@ def __init__(self, **kwargs): self.cert_url = kwargs.pop('cert_url') self.issuer = kwargs.pop('issuer') if self.short_name[0].lower() in 'aeiou': - self.articled_short_name = 'an {0}'.format(self.short_name) + self.articled_short_name = f'an {self.short_name}' else: - self.articled_short_name = 'a {0}'.format(self.short_name) + self.articled_short_name = f'a {self.short_name}' self._invalid_token_error = kwargs.pop('invalid_token_error') self._expired_token_error = kwargs.pop('expired_token_error') - def verify(self, token, request): + def verify(self, token, request, clock_skew_seconds=0): """Verifies the signature and data for the provided JWT.""" token = token.encode('utf-8') if isinstance(token, str) else token if not isinstance(token, bytes) or not token: raise ValueError( - 'Illegal {0} provided: {1}. {0} must be a non-empty ' - 'string.'.format(self.short_name, token)) + f'Illegal {self.short_name} provided: {token}. {self.short_name} must be a ' + 'non-empty string.') if not self.project_id: raise ValueError( 'Failed to ascertain project ID from the credential or the environment. Project ' - 'ID is required to call {0}. Initialize the app with a credentials.Certificate ' - 'or set your Firebase project ID as an app option. Alternatively set the ' - 'GOOGLE_CLOUD_PROJECT environment variable.'.format(self.operation)) + f'ID is required to call {self.operation}. Initialize the app with a ' + 'credentials.Certificate or set your Firebase project ID as an app option. ' + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.') + + if clock_skew_seconds < 0 or clock_skew_seconds > 60: + raise ValueError( + f'Illegal clock_skew_seconds value: {clock_skew_seconds}. Must be between 0 and 60' + ', inclusive.') header, payload = self._decode_unverified(token) issuer = payload.get('iss') @@ -280,64 +340,69 @@ def verify(self, token, request): expected_issuer = self.issuer + self.project_id project_id_match_msg = ( - 'Make sure the {0} comes from the same Firebase project as the service account used ' - 'to authenticate this SDK.'.format(self.short_name)) + f'Make sure the {self.short_name} comes from the same Firebase project as the service ' + 'account used to authenticate this SDK.') verify_id_token_msg = ( - 'See {0} for details on how to retrieve {1}.'.format(self.url, self.short_name)) + f'See {self.url} for details on how to retrieve {self.short_name}.') + + emulated = _auth_utils.is_emulated() error_message = None if audience == FIREBASE_AUDIENCE: error_message = ( - '{0} expects {1}, but was given a custom ' - 'token.'.format(self.operation, self.articled_short_name)) - elif not header.get('kid'): + f'{self.operation} expects {self.articled_short_name}, but was given a custom ' + 'token.') + elif not emulated and not header.get('kid'): if header.get('alg') == 'HS256' and payload.get( 'v') == 0 and 'uid' in payload.get('d', {}): error_message = ( - '{0} expects {1}, but was given a legacy custom ' - 'token.'.format(self.operation, self.articled_short_name)) + f'{self.operation} expects {self.articled_short_name}, but was given a legacy ' + 'custom token.') else: - error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name) - elif header.get('alg') != 'RS256': + error_message = f'Firebase {self.short_name} has no "kid" claim.' + elif not emulated and header.get('alg') != 'RS256': error_message = ( - 'Firebase {0} has incorrect algorithm. Expected "RS256" but got ' - '"{1}". {2}'.format(self.short_name, header.get('alg'), verify_id_token_msg)) + f'Firebase {self.short_name} has incorrect algorithm. Expected "RS256" but got ' + f'"{header.get("alg")}". {verify_id_token_msg}') elif audience != self.project_id: error_message = ( - 'Firebase {0} has incorrect "aud" (audience) claim. Expected "{1}" but ' - 'got "{2}". {3} {4}'.format(self.short_name, self.project_id, audience, - project_id_match_msg, verify_id_token_msg)) + f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected ' + f'"{self.project_id}" but got "{audience}". {project_id_match_msg} ' + f'{verify_id_token_msg}') elif issuer != expected_issuer: error_message = ( - 'Firebase {0} has incorrect "iss" (issuer) claim. Expected "{1}" but ' - 'got "{2}". {3} {4}'.format(self.short_name, expected_issuer, issuer, - project_id_match_msg, verify_id_token_msg)) + f'Firebase {self.short_name} has incorrect "iss" (issuer) claim. Expected ' + f'"{expected_issuer}" but got "{issuer}". {project_id_match_msg} ' + f'{verify_id_token_msg}') elif subject is None or not isinstance(subject, str): error_message = ( - 'Firebase {0} has no "sub" (subject) claim. ' - '{1}'.format(self.short_name, verify_id_token_msg)) + f'Firebase {self.short_name} has no "sub" (subject) claim. {verify_id_token_msg}') elif not subject: error_message = ( - 'Firebase {0} has an empty string "sub" (subject) claim. ' - '{1}'.format(self.short_name, verify_id_token_msg)) + f'Firebase {self.short_name} has an empty string "sub" (subject) claim. ' + f'{verify_id_token_msg}') elif len(subject) > 128: error_message = ( - 'Firebase {0} has a "sub" (subject) claim longer than 128 characters. ' - '{1}'.format(self.short_name, verify_id_token_msg)) + f'Firebase {self.short_name} has a "sub" (subject) claim longer than 128 ' + f'characters. {verify_id_token_msg}') if error_message: raise self._invalid_token_error(error_message) try: - verified_claims = google.oauth2.id_token.verify_token( - token, - request=request, - audience=self.project_id, - certs_url=self.cert_url) + if emulated: + verified_claims = payload + else: + verified_claims = google.oauth2.id_token.verify_token( + token, + request=request, + audience=self.project_id, + certs_url=self.cert_url, + clock_skew_in_seconds=clock_skew_seconds) verified_claims['uid'] = verified_claims['sub'] return verified_claims except google.auth.exceptions.TransportError as error: - raise CertificateFetchError(str(error), cause=error) + raise CertificateFetchError(str(error), cause=error) from error except ValueError as error: if 'Token expired' in str(error): raise self._expired_token_error(str(error), cause=error) diff --git a/firebase_admin/_user_import.py b/firebase_admin/_user_import.py index 7834b232a..7c7a9e70b 100644 --- a/firebase_admin/_user_import.py +++ b/firebase_admin/_user_import.py @@ -216,10 +216,10 @@ def provider_data(self): def provider_data(self, provider_data): if provider_data is not None: try: - if any([not isinstance(p, UserProvider) for p in provider_data]): + if any(not isinstance(p, UserProvider) for p in provider_data): raise ValueError('One or more provider data instances are invalid.') - except TypeError: - raise ValueError('provider_data must be iterable.') + except TypeError as err: + raise ValueError('provider_data must be iterable.') from err self._provider_data = provider_data @property @@ -454,7 +454,7 @@ def standard_scrypt(cls, memory_cost, parallelization, block_size, derived_key_l """Creates a new standard Scrypt algorithm instance. Args: - memory_cost: Memory cost as a non-negaive integer. + memory_cost: CPU Memory cost as a non-negative integer. parallelization: Parallelization as a non-negative integer. block_size: Block size as a non-negative integer. derived_key_length: Derived key length as a non-negative integer. @@ -463,7 +463,7 @@ def standard_scrypt(cls, memory_cost, parallelization, block_size, derived_key_l UserImportHash: A new ``UserImportHash``. """ data = { - 'memoryCost': _auth_utils.validate_int(memory_cost, 'memory_cost', low=0), + 'cpuMemCost': _auth_utils.validate_int(memory_cost, 'memory_cost', low=0), 'parallelization': _auth_utils.validate_int(parallelization, 'parallelization', low=0), 'blockSize': _auth_utils.validate_int(block_size, 'block_size', low=0), 'dkLen': _auth_utils.validate_int(derived_key_length, 'derived_key_length', low=0), diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 1d97dd504..e7825499c 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -17,7 +17,9 @@ import base64 from collections import defaultdict import json +from typing import Optional from urllib import parse +import warnings import requests @@ -128,9 +130,9 @@ class UserRecord(UserInfo): """Contains metadata associated with a Firebase user account.""" def __init__(self, data): - super(UserRecord, self).__init__() + super().__init__() if not isinstance(data, dict): - raise ValueError('Invalid data argument: {0}. Must be a dictionary.'.format(data)) + raise ValueError(f'Invalid data argument: {data}. Must be a dictionary.') if not data.get('localId'): raise ValueError('User ID must not be None or empty.') self._data = data @@ -452,9 +454,9 @@ class ProviderUserInfo(UserInfo): """Contains metadata regarding how a user is known by a particular identity provider.""" def __init__(self, data): - super(ProviderUserInfo, self).__init__() + super().__init__() if not isinstance(data, dict): - raise ValueError('Invalid data argument: {0}. Must be a dictionary.'.format(data)) + raise ValueError(f'Invalid data argument: {data}. Must be a dictionary.') if not data.get('rawId'): raise ValueError('User ID must not be None or empty.') self._data = data @@ -489,8 +491,22 @@ class ActionCodeSettings: Used when invoking the email action link generation APIs. """ - def __init__(self, url, handle_code_in_app=None, dynamic_link_domain=None, ios_bundle_id=None, - android_package_name=None, android_install_app=None, android_minimum_version=None): + def __init__( + self, + url: str, + handle_code_in_app: Optional[bool] = None, + dynamic_link_domain: Optional[str] = None, + ios_bundle_id: Optional[str] = None, + android_package_name: Optional[str] = None, + android_install_app: Optional[str] = None, + android_minimum_version: Optional[str] = None, + link_domain: Optional[str] = None, + ): + if dynamic_link_domain is not None: + warnings.warn( + 'dynamic_link_domain is deprecated, use link_domain instead', + DeprecationWarning + ) self.url = url self.handle_code_in_app = handle_code_in_app self.dynamic_link_domain = dynamic_link_domain @@ -498,6 +514,7 @@ def __init__(self, url, handle_code_in_app=None, dynamic_link_domain=None, ios_b self.android_package_name = android_package_name self.android_install_app = android_install_app self.android_minimum_version = android_minimum_version + self.link_domain = link_domain def encode_action_code_settings(settings): @@ -516,31 +533,38 @@ def encode_action_code_settings(settings): try: parsed = parse.urlparse(settings.url) if not parsed.netloc: - raise ValueError('Malformed dynamic action links url: "{0}".'.format(settings.url)) + raise ValueError(f'Malformed dynamic action links url: "{settings.url}".') parameters['continueUrl'] = settings.url - except Exception: - raise ValueError('Malformed dynamic action links url: "{0}".'.format(settings.url)) + except Exception as err: + raise ValueError(f'Malformed dynamic action links url: "{settings.url}".') from err # handle_code_in_app if settings.handle_code_in_app is not None: if not isinstance(settings.handle_code_in_app, bool): - raise ValueError('Invalid value provided for handle_code_in_app: {0}' - .format(settings.handle_code_in_app)) + raise ValueError( + f'Invalid value provided for handle_code_in_app: {settings.handle_code_in_app}') parameters['canHandleCodeInApp'] = settings.handle_code_in_app # dynamic_link_domain if settings.dynamic_link_domain is not None: if not isinstance(settings.dynamic_link_domain, str): - raise ValueError('Invalid value provided for dynamic_link_domain: {0}' - .format(settings.dynamic_link_domain)) + raise ValueError( + f'Invalid value provided for dynamic_link_domain: {settings.dynamic_link_domain}') parameters['dynamicLinkDomain'] = settings.dynamic_link_domain + # link_domain + if settings.link_domain is not None: + if not isinstance(settings.link_domain, str): + raise ValueError( + f'Invalid value provided for link_domain: {settings.link_domain}') + parameters['linkDomain'] = settings.link_domain + # ios_bundle_id if settings.ios_bundle_id is not None: if not isinstance(settings.ios_bundle_id, str): - raise ValueError('Invalid value provided for ios_bundle_id: {0}' - .format(settings.ios_bundle_id)) - parameters['iosBundleId'] = settings.ios_bundle_id + raise ValueError( + f'Invalid value provided for ios_bundle_id: {settings.ios_bundle_id}') + parameters['iOSBundleId'] = settings.ios_bundle_id # android_* attributes if (settings.android_minimum_version or settings.android_install_app) \ @@ -549,20 +573,21 @@ def encode_action_code_settings(settings): if settings.android_package_name is not None: if not isinstance(settings.android_package_name, str): - raise ValueError('Invalid value provided for android_package_name: {0}' - .format(settings.android_package_name)) + raise ValueError( + f'Invalid value provided for android_package_name: {settings.android_package_name}') parameters['androidPackageName'] = settings.android_package_name if settings.android_minimum_version is not None: if not isinstance(settings.android_minimum_version, str): - raise ValueError('Invalid value provided for android_minimum_version: {0}' - .format(settings.android_minimum_version)) + raise ValueError( + 'Invalid value provided for android_minimum_version: ' + f'{settings.android_minimum_version}') parameters['androidMinimumVersion'] = settings.android_minimum_version if settings.android_install_app is not None: if not isinstance(settings.android_install_app, bool): - raise ValueError('Invalid value provided for android_install_app: {0}' - .format(settings.android_install_app)) + raise ValueError( + f'Invalid value provided for android_install_app: {settings.android_install_app}') parameters['androidInstallApp'] = settings.android_install_app return parameters @@ -573,11 +598,12 @@ class UserManager: ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1' - def __init__(self, http_client, project_id, tenant_id=None): + def __init__(self, http_client, project_id, tenant_id=None, url_override=None): self.http_client = http_client - self.base_url = '{0}/projects/{1}'.format(self.ID_TOOLKIT_URL, project_id) + url_prefix = url_override or self.ID_TOOLKIT_URL + self.base_url = f'{url_prefix}/projects/{project_id}' if tenant_id: - self.base_url += '/tenants/{0}'.format(tenant_id) + self.base_url += f'/tenants/{tenant_id}' def get_user(self, **kwargs): """Gets the user data corresponding to the provided key.""" @@ -591,12 +617,12 @@ def get_user(self, **kwargs): key, key_type = kwargs.pop('phone_number'), 'phone number' payload = {'phoneNumber' : [_auth_utils.validate_phone(key, required=True)]} else: - raise TypeError('Unsupported keyword arguments: {0}.'.format(kwargs)) + raise TypeError(f'Unsupported keyword arguments: {kwargs}.') body, http_resp = self._make_request('post', '/accounts:lookup', json=payload) if not body or not body.get('users'): raise _auth_utils.UserNotFoundError( - 'No user record found for the provided {0}: {1}.'.format(key_type, key), + f'No user record found for the provided {key_type}: {key}.', http_response=http_resp) return body['users'][0] @@ -637,8 +663,7 @@ def get_users(self, identifiers): }) else: raise ValueError( - 'Invalid entry in "identifiers" list. Unsupported type: {}' - .format(type(identifier))) + f'Invalid entry in "identifiers" list. Unsupported type: {type(identifier)}') body, http_resp = self._make_request( 'post', '/accounts:lookup', json=payload) @@ -656,8 +681,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): raise ValueError('Max results must be an integer.') if max_results < 1 or max_results > MAX_LIST_USERS_RESULTS: raise ValueError( - 'Max results must be a positive integer less than ' - '{0}.'.format(MAX_LIST_USERS_RESULTS)) + f'Max results must be a positive integer less than {MAX_LIST_USERS_RESULTS}.') payload = {'maxResults': max_results} if page_token: @@ -687,7 +711,7 @@ def create_user(self, uid=None, display_name=None, email=None, phone_number=None def update_user(self, uid, display_name=None, email=None, phone_number=None, photo_url=None, password=None, disabled=None, email_verified=None, - valid_since=None, custom_claims=None): + valid_since=None, custom_claims=None, providers_to_delete=None): """Updates an existing user account with the specified properties""" payload = { 'localId': _auth_utils.validate_uid(uid, required=True), @@ -699,6 +723,7 @@ def update_user(self, uid, display_name=None, email=None, phone_number=None, } remove = [] + remove_provider = _auth_utils.validate_provider_ids(providers_to_delete) if display_name is not None: if display_name is DELETE_ATTRIBUTE: remove.append('DISPLAY_NAME') @@ -714,7 +739,7 @@ def update_user(self, uid, display_name=None, email=None, phone_number=None, if phone_number is not None: if phone_number is DELETE_ATTRIBUTE: - payload['deleteProvider'] = ['phone'] + remove_provider.append('phone') else: payload['phoneNumber'] = _auth_utils.validate_phone(phone_number) @@ -725,11 +750,14 @@ def update_user(self, uid, display_name=None, email=None, phone_number=None, custom_claims, dict) else custom_claims payload['customAttributes'] = _auth_utils.validate_custom_claims(json_claims) + if remove_provider: + payload['deleteProvider'] = list(set(remove_provider)) + payload = {k: v for k, v in payload.items() if v is not None} body, http_resp = self._make_request('post', '/accounts:update', json=payload) if not body or not body.get('localId'): raise _auth_utils.UnexpectedResponseError( - 'Failed to update user: {0}.'.format(uid), http_response=http_resp) + f'Failed to update user: {uid}.', http_response=http_resp) return body.get('localId') def delete_user(self, uid): @@ -738,7 +766,7 @@ def delete_user(self, uid): body, http_resp = self._make_request('post', '/accounts:delete', json={'localId' : uid}) if not body or not body.get('kind'): raise _auth_utils.UnexpectedResponseError( - 'Failed to delete user: {0}.'.format(uid), http_response=http_resp) + f'Failed to delete user: {uid}.', http_response=http_resp) def delete_users(self, uids, force_delete=False): """Deletes the users identified by the specified user ids. @@ -781,15 +809,15 @@ def import_users(self, users, hash_alg=None): try: if not users or len(users) > MAX_IMPORT_USERS_SIZE: raise ValueError( - 'Users must be a non-empty list with no more than {0} elements.'.format( - MAX_IMPORT_USERS_SIZE)) - if any([not isinstance(u, _user_import.ImportUserRecord) for u in users]): + 'Users must be a non-empty list with no more than ' + f'{MAX_IMPORT_USERS_SIZE} elements.') + if any(not isinstance(u, _user_import.ImportUserRecord) for u in users): raise ValueError('One or more user objects are invalid.') - except TypeError: - raise ValueError('users must be iterable') + except TypeError as err: + raise ValueError('users must be iterable') from err payload = {'users': [u.to_dict() for u in users]} - if any(['passwordHash' in u for u in payload['users']]): + if any('passwordHash' in u for u in payload['users']): if not isinstance(hash_alg, _user_import.UserImportHash): raise ValueError('A UserImportHash is required to import users with passwords.') payload.update(hash_alg.to_dict()) @@ -832,7 +860,7 @@ def generate_email_action_link(self, action_type, email, action_code_settings=No return body.get('oobLink') def _make_request(self, method, path, **kwargs): - url = '{0}{1}'.format(self.base_url, path) + url = f'{self.base_url}{path}' try: return self.http_client.body_and_response(method, url, **kwargs) except requests.exceptions.RequestException as error: diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index a5fc8d022..0277b9e5f 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -14,13 +14,13 @@ """Internal utilities common to all modules.""" -import io import json -import socket +from platform import python_version +from typing import Callable, Optional -import googleapiclient -import httplib2 +import google.auth import requests +import httpx import firebase_admin from firebase_admin import exceptions @@ -78,6 +78,8 @@ 16: exceptions.UNAUTHENTICATED, } +def get_metrics_header(): + return f'gl-python/{python_version()} fire-admin/{firebase_admin.__version__}' def _get_initialized_app(app): """Returns a reference to an initialized App instance.""" @@ -91,8 +93,9 @@ def _get_initialized_app(app): 'initialized via the firebase module.') return app - raise ValueError('Illegal app argument. Argument must be of type ' - ' firebase_admin.App, but given "{0}".'.format(type(app))) + raise ValueError( + 'Illegal app argument. Argument must be of type firebase_admin.App, but given ' + f'"{type(app)}".') @@ -128,6 +131,36 @@ def handle_platform_error_from_requests(error, handle_func=None): return exc if exc else _handle_func_requests(error, message, error_dict) +def handle_platform_error_from_httpx( + error: httpx.HTTPError, + handle_func: Optional[Callable[..., Optional[exceptions.FirebaseError]]] = None +) -> exceptions.FirebaseError: + """Constructs a ``FirebaseError`` from the given httpx error. + + This can be used to handle errors returned by Google Cloud Platform (GCP) APIs. + + Args: + error: An error raised by the httpx module while making an HTTP call to a GCP API. + handle_func: A function that can be used to handle platform errors in a custom way. When + specified, this function will be called with three arguments. It has the same + signature as ```_handle_func_httpx``, but may return ``None``. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code. + """ + + if isinstance(error, httpx.HTTPStatusError): + response = error.response + content = response.content.decode() + status_code = response.status_code + error_dict, message = _parse_platform_error(content, status_code) + exc = None + if handle_func: + exc = handle_func(error, message, error_dict) + + return exc if exc else _handle_func_httpx(error, message, error_dict) + return handle_httpx_error(error) + def handle_operation_error(error): """Constructs a ``FirebaseError`` from the given operation error. @@ -140,7 +173,7 @@ def handle_operation_error(error): """ if not isinstance(error, dict): return exceptions.UnknownError( - message='Unknown error while making a remote service call: {0}'.format(error), + message=f'Unknown error while making a remote service call: {error}', cause=error) rpc_code = error.get('code') @@ -185,15 +218,15 @@ def handle_requests_error(error, message=None, code=None): """ if isinstance(error, requests.exceptions.Timeout): return exceptions.DeadlineExceededError( - message='Timed out while making an API call: {0}'.format(error), + message=f'Timed out while making an API call: {error}', cause=error) if isinstance(error, requests.exceptions.ConnectionError): return exceptions.UnavailableError( - message='Failed to establish a connection: {0}'.format(error), + message=f'Failed to establish a connection: {error}', cause=error) if error.response is None: return exceptions.UnknownError( - message='Unknown error while making a remote service call: {0}'.format(error), + message=f'Unknown error while making a remote service call: {error}', cause=error) if not code: @@ -204,103 +237,59 @@ def handle_requests_error(error, message=None, code=None): err_type = _error_code_to_exception_type(code) return err_type(message=message, cause=error, http_response=error.response) - -def handle_platform_error_from_googleapiclient(error, handle_func=None): - """Constructs a ``FirebaseError`` from the given googleapiclient error. - - This can be used to handle errors returned by Google Cloud Platform (GCP) APIs. - - Args: - error: An error raised by the googleapiclient while making an HTTP call to a GCP API. - handle_func: A function that can be used to handle platform errors in a custom way. When - specified, this function will be called with three arguments. It has the same - signature as ```_handle_func_googleapiclient``, but may return ``None``. - - Returns: - FirebaseError: A ``FirebaseError`` that can be raised to the user code. - """ - if not isinstance(error, googleapiclient.errors.HttpError): - return handle_googleapiclient_error(error) - - content = error.content.decode() - status_code = error.resp.status - error_dict, message = _parse_platform_error(content, status_code) - http_response = _http_response_from_googleapiclient_error(error) - exc = None - if handle_func: - exc = handle_func(error, message, error_dict, http_response) - - return exc if exc else _handle_func_googleapiclient(error, message, error_dict, http_response) - - -def _handle_func_googleapiclient(error, message, error_dict, http_response): +def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exceptions.FirebaseError: """Constructs a ``FirebaseError`` from the given GCP error. Args: - error: An error raised by the googleapiclient module while making an HTTP call. + error: An error raised by the httpx module while making an HTTP call. message: A message to be included in the resulting ``FirebaseError``. error_dict: Parsed GCP error response. - http_response: A requests HTTP response object to associate with the exception. Returns: FirebaseError: A ``FirebaseError`` that can be raised to the user code or None. """ code = error_dict.get('status') - return handle_googleapiclient_error(error, message, code, http_response) + return handle_httpx_error(error, message, code) -def handle_googleapiclient_error(error, message=None, code=None, http_response=None): - """Constructs a ``FirebaseError`` from the given googleapiclient error. +def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> exceptions.FirebaseError: + """Constructs a ``FirebaseError`` from the given httpx error. This method is agnostic of the remote service that produced the error, whether it is a GCP service or otherwise. Therefore, this method does not attempt to parse the error response in any way. Args: - error: An error raised by the googleapiclient module while making an HTTP call. + error: An error raised by the httpx module while making an HTTP call. message: A message to be included in the resulting ``FirebaseError`` (optional). If not specified the string representation of the ``error`` argument is used as the message. code: A GCP error code that will be used to determine the resulting error type (optional). If not specified the HTTP status code on the error response is used to determine a suitable error code. - http_response: A requests HTTP response object to associate with the exception (optional). - If not specified, one will be created from the ``error``. Returns: FirebaseError: A ``FirebaseError`` that can be raised to the user code. """ - if isinstance(error, socket.timeout) or ( - isinstance(error, socket.error) and 'timed out' in str(error)): + if isinstance(error, httpx.TimeoutException): return exceptions.DeadlineExceededError( - message='Timed out while making an API call: {0}'.format(error), + message=f'Timed out while making an API call: {error}', cause=error) - if isinstance(error, httplib2.ServerNotFoundError): + if isinstance(error, httpx.ConnectError): return exceptions.UnavailableError( - message='Failed to establish a connection: {0}'.format(error), - cause=error) - if not isinstance(error, googleapiclient.errors.HttpError): - return exceptions.UnknownError( - message='Unknown error while making a remote service call: {0}'.format(error), + message=f'Failed to establish a connection: {error}', cause=error) + if isinstance(error, httpx.HTTPStatusError): + if not code: + code = _http_status_to_error_code(error.response.status_code) + if not message: + message = str(error) - if not code: - code = _http_status_to_error_code(error.resp.status) - if not message: - message = str(error) - if not http_response: - http_response = _http_response_from_googleapiclient_error(error) - - err_type = _error_code_to_exception_type(code) - return err_type(message=message, cause=error, http_response=http_response) - - -def _http_response_from_googleapiclient_error(error): - """Creates a requests HTTP Response object from the given googleapiclient error.""" - resp = requests.models.Response() - resp.raw = io.BytesIO(error.content) - resp.status_code = error.resp.status - return resp + err_type = _error_code_to_exception_type(code) + return err_type(message=message, cause=error, http_response=error.response) + return exceptions.UnknownError( + message=f'Unknown error while making a remote service call: {error}', + cause=error) def _http_status_to_error_code(status): """Maps an HTTP status to a platform error code.""" @@ -337,5 +326,22 @@ def _parse_platform_error(content, status_code): error_dict = data.get('error', {}) msg = error_dict.get('message') if not msg: - msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content) + msg = f'Unexpected HTTP response with status: {status_code}; body: {content}' return error_dict, msg + + +# Temporarily disable the lint rule. For more information see: +# https://github.com/googleapis/google-auth-library-python/pull/561 +# pylint: disable=abstract-method +class EmulatorAdminCredentials(google.auth.credentials.Credentials): + """ Credentials for use with the firebase local emulator. + + This is used instead of user-supplied credentials or ADC. It will silently do nothing when + asked to refresh credentials. + """ + def __init__(self): + google.auth.credentials.Credentials.__init__(self) + self.token = 'owner' + + def refresh(self, request): + pass diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py new file mode 100644 index 000000000..40d857f4e --- /dev/null +++ b/firebase_admin/app_check.py @@ -0,0 +1,161 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase App Check module.""" + +from typing import Any, Dict +import jwt +from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError, DecodeError +from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError +from firebase_admin import _utils + +_APP_CHECK_ATTRIBUTE = '_app_check' + +def _get_app_check_service(app) -> Any: + return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService) + +def verify_token(token: str, app=None) -> Dict[str, Any]: + """Verifies a Firebase App Check token. + + Args: + token: A token from App Check. + app: An App instance (optional). + + Returns: + Dict[str, Any]: The token's decoded claims. + + Raises: + ValueError: If the app's ``project_id`` is invalid or unspecified, + or if the token's headers or payload are invalid. + PyJWKClientError: If PyJWKClient fails to fetch a valid signing key. + """ + return _get_app_check_service(app).verify_token(token) + +class _AppCheckService: + """Service class that implements Firebase App Check functionality.""" + + _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/' + _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks' + _project_id = None + _scoped_project_id = None + _jwks_client = None + + _APP_CHECK_HEADERS = { + 'x-goog-api-client': _utils.get_metrics_header(), + } + + def __init__(self, app): + # Validate and store the project_id to validate the JWT claims + self._project_id = app.project_id + if not self._project_id: + raise ValueError( + 'A project ID must be specified to access the App Check ' + 'service. Either set the projectId option, use service ' + 'account credentials, or set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + self._scoped_project_id = 'projects/' + app.project_id + # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours). + self._jwks_client = PyJWKClient( + self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS) + + + def verify_token(self, token: str) -> Dict[str, Any]: + """Verifies a Firebase App Check token.""" + _Validators.check_string("app check token", token) + + # Obtain the Firebase App Check Public Keys + # Note: It is not recommended to hard code these keys as they rotate, + # but you should cache them for up to 6 hours. + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + self._has_valid_token_headers(jwt.get_unverified_header(token)) + verified_claims = self._decode_and_verify(token, signing_key.key) + except (InvalidTokenError, DecodeError) as exception: + raise ValueError( + f'Verifying App Check token failed. Error: {exception}' + ) from exception + + verified_claims['app_id'] = verified_claims.get('sub') + return verified_claims + + def _has_valid_token_headers(self, headers: Any) -> None: + """Checks whether the token has valid headers for App Check.""" + # Ensure the token's header has type JWT + if headers.get('typ') != 'JWT': + raise ValueError("The provided App Check token has an incorrect type header") + # Ensure the token's header uses the algorithm RS256 + algorithm = headers.get('alg') + if algorithm != 'RS256': + raise ValueError( + 'The provided App Check token has an incorrect alg header. ' + f'Expected RS256 but got {algorithm}.' + ) + + def _decode_and_verify(self, token: str, signing_key: str): + """Decodes and verifies the token from App Check.""" + payload = {} + try: + payload = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=self._scoped_project_id + ) + except InvalidSignatureError as exception: + raise ValueError( + 'The provided App Check token has an invalid signature.' + ) from exception + except InvalidAudienceError as exception: + raise ValueError( + 'The provided App Check token has an incorrect "aud" (audience) claim. ' + f'Expected payload to include {self._scoped_project_id}.' + ) from exception + except InvalidIssuerError as exception: + raise ValueError( + 'The provided App Check token has an incorrect "iss" (issuer) claim. ' + f'Expected claim to include {self._APP_CHECK_ISSUER}' + ) from exception + except ExpiredSignatureError as exception: + raise ValueError( + 'The provided App Check token has expired.' + ) from exception + except InvalidTokenError as exception: + raise ValueError( + f'Decoding App Check token failed. Error: {exception}' + ) from exception + + audience = payload.get('aud') + if not isinstance(audience, list) or self._scoped_project_id not in audience: + raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') + if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): + raise ValueError('Token does not contain the correct "iss" (issuer).') + _Validators.check_string( + 'The provided App Check token "sub" (subject) claim', + payload.get('sub')) + + return payload + +class _Validators: + """A collection of data validation utilities. + + Methods provided in this class raise ``ValueErrors`` if any validations fail. + """ + + @classmethod + def check_string(cls, label: str, value: Any): + """Checks if the given value is a string.""" + if value is None: + raise ValueError(f'{label} "{value}" must be a non-empty string.') + if not isinstance(value, str): + raise ValueError(f'{label} "{value}" must be a string.') diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index c5361bc38..cb63ab7f0 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -39,6 +39,7 @@ 'ConfigurationNotFoundError', 'DELETE_ATTRIBUTE', 'EmailAlreadyExistsError', + 'EmailNotFoundError', 'ErrorInfo', 'ExpiredIdTokenError', 'ExpiredSessionCookieError', @@ -48,6 +49,7 @@ 'ImportUserRecord', 'InsufficientPermissionError', 'InvalidDynamicLinkDomainError', + 'InvalidHostingLinkDomainError', 'InvalidIdTokenError', 'InvalidSessionCookieError', 'ListProviderConfigsPage', @@ -55,12 +57,15 @@ 'OIDCProviderConfig', 'PhoneNumberAlreadyExistsError', 'ProviderConfig', + 'ResetPasswordExceedLimitError', 'RevokedIdTokenError', 'RevokedSessionCookieError', 'SAMLProviderConfig', 'TokenSignError', + 'TooManyAttemptsTryLaterError', 'UidAlreadyExistsError', 'UnexpectedResponseError', + 'UserDisabledError', 'UserImportHash', 'UserImportResult', 'UserInfo', @@ -112,6 +117,7 @@ DELETE_ATTRIBUTE = _user_mgt.DELETE_ATTRIBUTE DeleteUsersResult = _user_mgt.DeleteUsersResult EmailAlreadyExistsError = _auth_utils.EmailAlreadyExistsError +EmailNotFoundError = _auth_utils.EmailNotFoundError ErrorInfo = _user_import.ErrorInfo ExpiredIdTokenError = _token_gen.ExpiredIdTokenError ExpiredSessionCookieError = _token_gen.ExpiredSessionCookieError @@ -120,6 +126,7 @@ ImportUserRecord = _user_import.ImportUserRecord InsufficientPermissionError = _auth_utils.InsufficientPermissionError InvalidDynamicLinkDomainError = _auth_utils.InvalidDynamicLinkDomainError +InvalidHostingLinkDomainError = _auth_utils.InvalidHostingLinkDomainError InvalidIdTokenError = _auth_utils.InvalidIdTokenError InvalidSessionCookieError = _token_gen.InvalidSessionCookieError ListProviderConfigsPage = _auth_providers.ListProviderConfigsPage @@ -127,12 +134,15 @@ OIDCProviderConfig = _auth_providers.OIDCProviderConfig PhoneNumberAlreadyExistsError = _auth_utils.PhoneNumberAlreadyExistsError ProviderConfig = _auth_providers.ProviderConfig +ResetPasswordExceedLimitError = _auth_utils.ResetPasswordExceedLimitError RevokedIdTokenError = _token_gen.RevokedIdTokenError RevokedSessionCookieError = _token_gen.RevokedSessionCookieError SAMLProviderConfig = _auth_providers.SAMLProviderConfig TokenSignError = _token_gen.TokenSignError +TooManyAttemptsTryLaterError = _auth_utils.TooManyAttemptsTryLaterError UidAlreadyExistsError = _auth_utils.UidAlreadyExistsError UnexpectedResponseError = _auth_utils.UnexpectedResponseError +UserDisabledError = _auth_utils.UserDisabledError UserImportHash = _user_import.UserImportHash UserImportResult = _user_import.UserImportResult UserInfo = _user_mgt.UserInfo @@ -187,7 +197,7 @@ def create_custom_token(uid, developer_claims=None, app=None): return client.create_custom_token(uid, developer_claims) -def verify_id_token(id_token, app=None, check_revoked=False): +def verify_id_token(id_token, app=None, check_revoked=False, clock_skew_seconds=0): """Verifies the signature and data for the provided JWT. Accepts a signed token string, verifies that it is current, and issued @@ -196,8 +206,10 @@ def verify_id_token(id_token, app=None, check_revoked=False): Args: id_token: A string of the encoded JWT. app: An App instance (optional). - check_revoked: Boolean, If true, checks whether the token has been revoked (optional). - + check_revoked: Boolean, If true, checks whether the token has been revoked or + the user disabled (optional). + clock_skew_seconds: The number of seconds to tolerate when checking the token. + Must be between 0-60. Defaults to 0. Returns: dict: A dictionary of key-value pairs parsed from the decoded JWT. @@ -208,9 +220,12 @@ def verify_id_token(id_token, app=None, check_revoked=False): RevokedIdTokenError: If ``check_revoked`` is ``True`` and the ID token has been revoked. CertificateFetchError: If an error occurs while fetching the public key certificates required to verify the ID token. + UserDisabledError: If ``check_revoked`` is ``True`` and the corresponding user + record is disabled. """ client = _get_client(app) - return client.verify_id_token(id_token, check_revoked=check_revoked) + return client.verify_id_token( + id_token, check_revoked=check_revoked, clock_skew_seconds=clock_skew_seconds) def create_session_cookie(id_token, expires_in, app=None): @@ -236,7 +251,7 @@ def create_session_cookie(id_token, expires_in, app=None): return client._token_generator.create_session_cookie(id_token, expires_in) -def verify_session_cookie(session_cookie, check_revoked=False, app=None): +def verify_session_cookie(session_cookie, check_revoked=False, app=None, clock_skew_seconds=0): """Verifies a Firebase session cookie. Accepts a session cookie string, verifies that it is current, and issued @@ -244,8 +259,10 @@ def verify_session_cookie(session_cookie, check_revoked=False, app=None): Args: session_cookie: A session cookie string to verify. - check_revoked: Boolean, if true, checks whether the cookie has been revoked (optional). + check_revoked: Boolean, if true, checks whether the cookie has been revoked or the + user disabled (optional). app: An App instance (optional). + clock_skew_seconds: The number of seconds to tolerate when checking the cookie. Returns: dict: A dictionary of key-value pairs parsed from the decoded JWT. @@ -257,12 +274,16 @@ def verify_session_cookie(session_cookie, check_revoked=False, app=None): RevokedSessionCookieError: If ``check_revoked`` is ``True`` and the cookie has been revoked. CertificateFetchError: If an error occurs while fetching the public key certificates required to verify the session cookie. + UserDisabledError: If ``check_revoked`` is ``True`` and the corresponding user + record is disabled. """ client = _get_client(app) # pylint: disable=protected-access - verified_claims = client._token_verifier.verify_session_cookie(session_cookie) + verified_claims = client._token_verifier.verify_session_cookie( + session_cookie, clock_skew_seconds) if check_revoked: - client._check_jwt_revoked(verified_claims, RevokedSessionCookieError, 'session cookie') + client._check_jwt_revoked_or_disabled( + verified_claims, RevokedSessionCookieError, 'session cookie') return verified_claims @@ -405,7 +426,7 @@ def create_user(**kwargs): # pylint: disable=differing-param-doc """Creates a new user account with the specified properties. Args: - kwargs: A series of keyword arguments (optional). + **kwargs: A series of keyword arguments (optional). Keyword Args: uid: User ID to assign to the newly created user (optional). @@ -436,7 +457,7 @@ def update_user(uid, **kwargs): # pylint: disable=differing-param-doc Args: uid: A user ID string. - kwargs: A series of keyword arguments (optional). + **kwargs: A series of keyword arguments (optional). Keyword Args: display_name: The user's display name (optional). Can be removed by explicitly passing @@ -450,7 +471,7 @@ def update_user(uid, **kwargs): # pylint: disable=differing-param-doc ``auth.DELETE_ATTRIBUTE``. password: The user's raw, unhashed password. (optional). disabled: A boolean indicating whether or not the user account is disabled (optional). - custom_claims: A dictionary or a JSON string contining the custom claims to be set on the + custom_claims: A dictionary or a JSON string containing the custom claims to be set on the user account (optional). To remove all custom claims, pass ``auth.DELETE_ATTRIBUTE``. valid_since: An integer signifying the seconds since the epoch (optional). This field is set by ``revoke_refresh_tokens`` and it is discouraged to set this field directly. @@ -645,7 +666,8 @@ def get_oidc_provider_config(provider_id, app=None): return client.get_oidc_provider_config(provider_id) def create_oidc_provider_config( - provider_id, client_id, issuer, display_name=None, enabled=None, app=None): + provider_id, client_id, issuer, display_name=None, enabled=None, client_secret=None, + id_token_response_type=None, code_response_type=None, app=None): """Creates a new OIDC provider config from the given parameters. OIDC provider support requires Google Cloud's Identity Platform (GCIP). To learn more about @@ -660,6 +682,15 @@ def create_oidc_provider_config( enabled: A boolean indicating whether the provider configuration is enabled or disabled (optional). A user cannot sign in using a disabled provider. app: An App instance (optional). + client_secret: A string which sets the client secret for the new provider. + This is required for the code flow. + code_response_type: A boolean which sets whether to enable the code response flow for the + new provider. By default, this is not enabled if no response type is specified. + A client secret must be set for this response type. + Having both the code and ID token response flows is currently not supported. + id_token_response_type: A boolean which sets whether to enable the ID token response flow + for the new provider. By default, this is enabled if no response type is specified. + Having both the code and ID token response flows is currently not supported. Returns: OIDCProviderConfig: The newly created OIDC provider config instance. @@ -671,11 +702,13 @@ def create_oidc_provider_config( client = _get_client(app) return client.create_oidc_provider_config( provider_id, client_id=client_id, issuer=issuer, display_name=display_name, - enabled=enabled) + enabled=enabled, client_secret=client_secret, id_token_response_type=id_token_response_type, + code_response_type=code_response_type) def update_oidc_provider_config( - provider_id, client_id=None, issuer=None, display_name=None, enabled=None, app=None): + provider_id, client_id=None, issuer=None, display_name=None, enabled=None, + client_secret=None, id_token_response_type=None, code_response_type=None, app=None): """Updates an existing OIDC provider config with the given parameters. Args: @@ -687,6 +720,15 @@ def update_oidc_provider_config( enabled: A boolean indicating whether the provider configuration is enabled or disabled (optional). app: An App instance (optional). + client_secret: A string which sets the client secret for the new provider. + This is required for the code flow. + code_response_type: A boolean which sets whether to enable the code response flow for the + new provider. By default, this is not enabled if no response type is specified. + A client secret must be set for this response type. + Having both the code and ID token response flows is currently not supported. + id_token_response_type: A boolean which sets whether to enable the ID token response flow + for the new provider. By default, this is enabled if no response type is specified. + Having both the code and ID token response flows is currently not supported. Returns: OIDCProviderConfig: The updated OIDC provider config instance. @@ -698,7 +740,8 @@ def update_oidc_provider_config( client = _get_client(app) return client.update_oidc_provider_config( provider_id, client_id=client_id, issuer=issuer, display_name=display_name, - enabled=enabled) + enabled=enabled, client_secret=client_secret, id_token_response_type=id_token_response_type, + code_response_type=code_response_type) def delete_oidc_provider_config(provider_id, app=None): diff --git a/firebase_admin/credentials.py b/firebase_admin/credentials.py index 8f9c504f0..0edbecaae 100644 --- a/firebase_admin/credentials.py +++ b/firebase_admin/credentials.py @@ -15,8 +15,10 @@ """Firebase credentials module.""" import collections import json +import pathlib import google.auth +from google.auth.credentials import Credentials as GoogleAuthCredentials from google.auth.transport import requests from google.oauth2 import credentials from google.oauth2 import service_account @@ -35,7 +37,7 @@ AccessTokenInfo = collections.namedtuple('AccessTokenInfo', ['access_token', 'expiry']) """Data included in an OAuth2 access token. -Contains the access token string and the expiry time. The expirty time is exposed as a +Contains the access token string and the expiry time. The expiry time is exposed as a ``datetime`` value. """ @@ -57,6 +59,19 @@ def get_credential(self): """Returns the Google credential instance used for authentication.""" raise NotImplementedError +class _ExternalCredentials(Base): + """A wrapper for google.auth.credentials.Credentials typed credential instances""" + + def __init__(self, credential: GoogleAuthCredentials): + super().__init__() + self._g_credential = credential + + def get_credential(self): + """Returns the underlying Google Credential + + Returns: + google.auth.credentials.Credentials: A Google Auth credential instance.""" + return self._g_credential class Certificate(Base): """A credential initialized from a JSON certificate keyfile.""" @@ -77,26 +92,27 @@ def __init__(self, cert): IOError: If the specified certificate file doesn't exist or cannot be read. ValueError: If the specified certificate is invalid. """ - super(Certificate, self).__init__() - if isinstance(cert, str): - with open(cert) as json_file: + super().__init__() + if _is_file_path(cert): + with open(cert, encoding='utf-8') as json_file: json_data = json.load(json_file) elif isinstance(cert, dict): json_data = cert else: raise ValueError( - 'Invalid certificate argument: "{0}". Certificate argument must be a file path, ' - 'or a dict containing the parsed file contents.'.format(cert)) + f'Invalid certificate argument: "{cert}". Certificate argument must be a file ' + 'path, or a dict containing the parsed file contents.') if json_data.get('type') != self._CREDENTIAL_TYPE: - raise ValueError('Invalid service account certificate. Certificate must contain a ' - '"type" field set to "{0}".'.format(self._CREDENTIAL_TYPE)) + raise ValueError( + 'Invalid service account certificate. Certificate must contain a ' + f'"type" field set to "{self._CREDENTIAL_TYPE}".') try: self._g_credential = service_account.Credentials.from_service_account_info( json_data, scopes=_scopes) except ValueError as error: - raise ValueError('Failed to initialize a certificate credential. ' - 'Caused by: "{0}"'.format(error)) + raise ValueError( + f'Failed to initialize a certificate credential. Caused by: "{error}"') from error @property def project_id(self): @@ -127,7 +143,7 @@ def __init__(self): The credentials will be lazily initialized when get_credential() or project_id() is called. See those methods for possible errors raised. """ - super(ApplicationDefault, self).__init__() + super().__init__() self._g_credential = None # Will be lazily-loaded via _load_credential(). def get_credential(self): @@ -165,7 +181,7 @@ class RefreshToken(Base): def __init__(self, refresh_token): """Initializes a credential from a refresh token JSON file. - The JSON must consist of client_id, client_secert and refresh_token fields. Refresh + The JSON must consist of client_id, client_secret and refresh_token fields. Refresh token files are typically created and managed by the gcloud SDK. To instantiate a credential from a refresh token file, either specify the file path or a dict representing the parsed contents of the file. @@ -178,20 +194,21 @@ def __init__(self, refresh_token): IOError: If the specified file doesn't exist or cannot be read. ValueError: If the refresh token configuration is invalid. """ - super(RefreshToken, self).__init__() - if isinstance(refresh_token, str): - with open(refresh_token) as json_file: + super().__init__() + if _is_file_path(refresh_token): + with open(refresh_token, encoding='utf-8') as json_file: json_data = json.load(json_file) elif isinstance(refresh_token, dict): json_data = refresh_token else: raise ValueError( - 'Invalid refresh token argument: "{0}". Refresh token argument must be a file ' - 'path, or a dict containing the parsed file contents.'.format(refresh_token)) + f'Invalid refresh token argument: "{refresh_token}". Refresh token argument must ' + 'be a file path, or a dict containing the parsed file contents.') if json_data.get('type') != self._CREDENTIAL_TYPE: - raise ValueError('Invalid refresh token configuration. JSON must contain a ' - '"type" field set to "{0}".'.format(self._CREDENTIAL_TYPE)) + raise ValueError( + 'Invalid refresh token configuration. JSON must contain a ' + f'"type" field set to "{self._CREDENTIAL_TYPE}".') self._g_credential = credentials.Credentials.from_authorized_user_info(json_data, _scopes) @property @@ -212,3 +229,11 @@ def get_credential(self): Returns: google.auth.credentials.Credentials: A Google Auth credential instance.""" return self._g_credential + + +def _is_file_path(path): + try: + pathlib.Path(path) + return True + except TypeError: + return False diff --git a/firebase_admin/db.py b/firebase_admin/db.py index d42370317..800cbf8e3 100644 --- a/firebase_admin/db.py +++ b/firebase_admin/db.py @@ -27,7 +27,6 @@ import threading from urllib import parse -import google.auth import requests import firebase_admin @@ -40,8 +39,10 @@ _DB_ATTRIBUTE = '_database' _INVALID_PATH_CHARACTERS = '[].?#$' _RESERVED_FILTERS = ('$key', '$value', '$priority') -_USER_AGENT = 'Firebase/HTTP/{0}/{1}.{2}/AdminPython'.format( - firebase_admin.__version__, sys.version_info.major, sys.version_info.minor) +_USER_AGENT = ( + f'Firebase/HTTP/{firebase_admin.__version__}/{sys.version_info.major}' + f'.{sys.version_info.minor}/AdminPython' +) _TRANSACTION_MAX_RETRIES = 25 _EMULATOR_HOST_ENV_VAR = 'FIREBASE_DATABASE_EMULATOR_HOST' @@ -73,10 +74,9 @@ def reference(path='/', app=None, url=None): def _parse_path(path): """Parses a path string into a set of segments.""" if not isinstance(path, str): - raise ValueError('Invalid path: "{0}". Path must be a string.'.format(path)) + raise ValueError(f'Invalid path: "{path}". Path must be a string.') if any(ch in path for ch in _INVALID_PATH_CHARACTERS): - raise ValueError( - 'Invalid path: "{0}". Path contains illegal characters.'.format(path)) + raise ValueError(f'Invalid path: "{path}". Path contains illegal characters.') return [seg for seg in path.split('/') if seg] @@ -185,11 +185,9 @@ def child(self, path): ValueError: If the child path is not a string, not well-formed or begins with '/'. """ if not path or not isinstance(path, str): - raise ValueError( - 'Invalid path argument: "{0}". Path must be a non-empty string.'.format(path)) + raise ValueError(f'Invalid path argument: "{path}". Path must be a non-empty string.') if path.startswith('/'): - raise ValueError( - 'Invalid path argument: "{0}". Child path must not start with "/"'.format(path)) + raise ValueError(f'Invalid path argument: "{path}". Child path must not start with "/"') full_path = self._pathurl + '/' + path return Reference(client=self._client, path=full_path) @@ -434,7 +432,7 @@ def order_by_child(self, path): ValueError: If the child path is not a string, not well-formed or None. """ if path in _RESERVED_FILTERS: - raise ValueError('Illegal child path: {0}'.format(path)) + raise ValueError(f'Illegal child path: {path}') return Query(order_by=path, client=self._client, pathurl=self._add_suffix()) def order_by_key(self): @@ -468,7 +466,7 @@ def _listen_with_session(self, callback, session=None): session = self._client.create_listener_session() try: - sse = _sseclient.SSEClient(url, session) + sse = _sseclient.SSEClient(url, session, **{"params": self._client.params}) return ListenerRegistration(callback, sse) except requests.exceptions.RequestException as error: raise _Client.handle_rtdb_error(error) @@ -493,8 +491,8 @@ def __init__(self, **kwargs): raise ValueError('order_by field must be a non-empty string') if order_by not in _RESERVED_FILTERS: if order_by.startswith('/'): - raise ValueError('Invalid path argument: "{0}". Child path must not start ' - 'with "/"'.format(order_by)) + raise ValueError( + f'Invalid path argument: "{order_by}". Child path must not start with "/"') segments = _parse_path(order_by) order_by = '/'.join(segments) self._client = kwargs.pop('client') @@ -502,7 +500,7 @@ def __init__(self, **kwargs): self._order_by = order_by self._params = {'orderBy' : json.dumps(order_by)} if kwargs: - raise ValueError('Unexpected keyword arguments: {0}'.format(kwargs)) + raise ValueError(f'Unexpected keyword arguments: {kwargs}') def limit_to_first(self, limit): """Creates a query with limit, and anchors it to the start of the window. @@ -605,7 +603,7 @@ def equal_to(self, value): def _querystr(self): params = [] for key in sorted(self._params): - params.append('{0}={1}'.format(key, self._params[key])) + params.append(f'{key}={self._params[key]}') return '&'.join(params) def get(self): @@ -643,7 +641,7 @@ def __init__(self, results, order_by): self.dict_input = False entries = [_SortEntry(k, v, order_by) for k, v in enumerate(results)] else: - raise ValueError('Sorting not supported for "{0}" object.'.format(type(results))) + raise ValueError(f'Sorting not supported for "{type(results)}" object.') self.sort_entries = sorted(entries) def get(self): @@ -768,10 +766,10 @@ def __init__(self, app): self._credential = app.credential db_url = app.options.get('databaseURL') if db_url: - _DatabaseService._parse_db_url(db_url) # Just for validation. self._db_url = db_url else: self._db_url = None + auth_override = _DatabaseService._get_auth_override(app) if auth_override not in (self._DEFAULT_AUTH_OVERRIDE, {}): self._auth_override = json.dumps(auth_override, separators=(',', ':')) @@ -784,8 +782,8 @@ def __init__(self, app): if emulator_host: if '//' in emulator_host: raise ValueError( - 'Invalid {0}: "{1}". It must follow format "host:port".'.format( - _EMULATOR_HOST_ENV_VAR, emulator_host)) + f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' + '"host:port".') self._emulator_host = emulator_host else: self._emulator_host = None @@ -795,15 +793,27 @@ def get_client(self, db_url=None): if db_url is None: db_url = self._db_url - base_url, namespace = _DatabaseService._parse_db_url(db_url, self._emulator_host) - if base_url == 'https://{0}.firebaseio.com'.format(namespace): - # Production base_url. No need to specify namespace in query params. - params = {} - credential = self._credential.get_credential() + if not db_url or not isinstance(db_url, str): + raise ValueError( + f'Invalid database URL: "{db_url}". Database URL must be a non-empty URL string.') + + parsed_url = parse.urlparse(db_url) + if not parsed_url.netloc: + raise ValueError( + f'Invalid database URL: "{db_url}". Database URL must be a wellformed URL string.') + + emulator_config = self._get_emulator_config(parsed_url) + if emulator_config: + credential = _utils.EmulatorAdminCredentials() + base_url = emulator_config.base_url + params = {'ns': emulator_config.namespace} else: - # Emulator base_url. Use fake credentials and specify ?ns=foo in query params. - credential = _EmulatorAdminCredentials() - params = {'ns': namespace} + # Defer credential lookup until we are certain it's going to be prod connection. + credential = self._credential.get_credential() + base_url = f'https://{parsed_url.netloc}' + params = {} + + if self._auth_override: params['auth_variable_override'] = self._auth_override @@ -813,47 +823,20 @@ def get_client(self, db_url=None): self._clients[client_cache_key] = client return self._clients[client_cache_key] - @classmethod - def _parse_db_url(cls, url, emulator_host=None): - """Parses (base_url, namespace) from a database URL. - - The input can be either a production URL (https://foo-bar.firebaseio.com/) - or an Emulator URL (http://localhost:8080/?ns=foo-bar). In case of Emulator - URL, the namespace is extracted from the query param ns. The resulting - base_url never includes query params. - - If url is a production URL and emulator_host is specified, the result - base URL will use emulator_host instead. emulator_host is ignored - if url is already an emulator URL. - """ - if not url or not isinstance(url, str): - raise ValueError( - 'Invalid database URL: "{0}". Database URL must be a non-empty ' - 'URL string.'.format(url)) - parsed_url = parse.urlparse(url) - if parsed_url.netloc.endswith('.firebaseio.com'): - return cls._parse_production_url(parsed_url, emulator_host) - - return cls._parse_emulator_url(parsed_url) - - @classmethod - def _parse_production_url(cls, parsed_url, emulator_host): - """Parses production URL like https://foo-bar.firebaseio.com/""" + def _get_emulator_config(self, parsed_url): + """Checks whether the SDK should connect to the RTDB emulator.""" + EmulatorConfig = collections.namedtuple('EmulatorConfig', ['base_url', 'namespace']) if parsed_url.scheme != 'https': - raise ValueError( - 'Invalid database URL scheme: "{0}". Database URL must be an HTTPS URL.'.format( - parsed_url.scheme)) - namespace = parsed_url.netloc.split('.')[0] - if not namespace: - raise ValueError( - 'Invalid database URL: "{0}". Database URL must be a valid URL to a ' - 'Firebase Realtime Database instance.'.format(parsed_url.geturl())) + # Emulator mode enabled by passing http URL via AppOptions + base_url, namespace = _DatabaseService._parse_emulator_url(parsed_url) + return EmulatorConfig(base_url, namespace) + if self._emulator_host: + # Emulator mode enabled via environment variable + base_url = f'http://{self._emulator_host}' + namespace = parsed_url.netloc.split('.')[0] + return EmulatorConfig(base_url, namespace) - if emulator_host: - base_url = 'http://{0}'.format(emulator_host) - else: - base_url = 'https://{0}'.format(parsed_url.netloc) - return base_url, namespace + return None @classmethod def _parse_emulator_url(cls, parsed_url): @@ -861,21 +844,23 @@ def _parse_emulator_url(cls, parsed_url): query_ns = parse.parse_qs(parsed_url.query).get('ns') if parsed_url.scheme != 'http' or (not query_ns or len(query_ns) != 1 or not query_ns[0]): raise ValueError( - 'Invalid database URL: "{0}". Database URL must be a valid URL to a ' - 'Firebase Realtime Database instance.'.format(parsed_url.geturl())) + f'Invalid database URL: "{parsed_url.geturl()}". Database URL must be a valid URL ' + 'to a Firebase Realtime Database instance.') namespace = query_ns[0] - base_url = '{0}://{1}'.format(parsed_url.scheme, parsed_url.netloc) + base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' return base_url, namespace @classmethod def _get_auth_override(cls, app): + """Gets and validates the database auth override to be used.""" auth_override = app.options.get('databaseAuthVariableOverride', cls._DEFAULT_AUTH_OVERRIDE) if auth_override == cls._DEFAULT_AUTH_OVERRIDE or auth_override is None: return auth_override if not isinstance(auth_override, dict): - raise ValueError('Invalid databaseAuthVariableOverride option: "{0}". Override ' - 'value must be a dict or None.'.format(auth_override)) + raise ValueError( + f'Invalid databaseAuthVariableOverride option: "{auth_override}". Override ' + 'value must be a dict or None.') return auth_override @@ -921,7 +906,7 @@ def request(self, method, url, **kwargs): Args: method: HTTP method name as a string (e.g. get, post). url: URL path of the remote endpoint. This will be appended to the server's base URL. - kwargs: An additional set of keyword arguments to be passed into requests API + **kwargs: An additional set of keyword arguments to be passed into requests API (e.g. json, params). Returns: @@ -930,7 +915,7 @@ def request(self, method, url, **kwargs): Raises: FirebaseError: If an error occurs while making the HTTP call. """ - query = '&'.join('{0}={1}'.format(key, self.params[key]) for key in self.params) + query = '&'.join(f'{key}={value}' for key, value in self.params.items()) extra_params = kwargs.get('params') if extra_params: if query: @@ -940,7 +925,7 @@ def request(self, method, url, **kwargs): kwargs['params'] = query try: - return super(_Client, self).request(method, url, **kwargs) + return super().request(method, url, **kwargs) except requests.exceptions.RequestException as error: raise _Client.handle_rtdb_error(error) @@ -975,15 +960,6 @@ def _extract_error_message(cls, response): pass if not message: - message = 'Unexpected response from database: {0}'.format(response.content.decode()) + message = f'Unexpected response from database: {response.content.decode()}' return message - - -class _EmulatorAdminCredentials(google.auth.credentials.Credentials): - def __init__(self): - google.auth.credentials.Credentials.__init__(self) - self.token = 'owner' - - def refresh(self, request): - pass diff --git a/firebase_admin/exceptions.py b/firebase_admin/exceptions.py index 06504225f..947f36806 100644 --- a/firebase_admin/exceptions.py +++ b/firebase_admin/exceptions.py @@ -91,7 +91,7 @@ class FirebaseError(Exception): cause: The exception that caused this error (optional). http_response: If this error was caused by an HTTP error response, this property is set to the ``requests.Response`` object that represents the HTTP response (optional). - See https://2.python-requests.org/en/master/api/#requests.Response for details of + See https://docs.python-requests.org/en/master/api/#requests.Response for details of this object. """ diff --git a/firebase_admin/firestore.py b/firebase_admin/firestore.py index 32c9897d5..52ea90671 100644 --- a/firebase_admin/firestore.py +++ b/firebase_admin/firestore.py @@ -18,59 +18,75 @@ Firebase apps. This requires the ``google-cloud-firestore`` Python module. """ +from __future__ import annotations +from typing import Optional, Dict +from firebase_admin import App +from firebase_admin import _utils + try: - from google.cloud import firestore # pylint: disable=import-error,no-name-in-module + from google.cloud import firestore + from google.cloud.firestore_v1.base_client import DEFAULT_DATABASE existing = globals().keys() for key, value in firestore.__dict__.items(): if not key.startswith('_') and key not in existing: globals()[key] = value -except ImportError: +except ImportError as error: raise ImportError('Failed to import the Cloud Firestore library for Python. Make sure ' - 'to install the "google-cloud-firestore" module.') - -from firebase_admin import _utils + 'to install the "google-cloud-firestore" module.') from error _FIRESTORE_ATTRIBUTE = '_firestore' -def client(app=None): +def client(app: Optional[App] = None, database_id: Optional[str] = None) -> firestore.Client: """Returns a client that can be used to interact with Google Cloud Firestore. Args: - app: An App instance (optional). + app: An App instance (optional). + database_id: The database ID of the Google Cloud Firestore database to be used. + Defaults to the default Firestore database ID if not specified or an empty string + (optional). Returns: - google.cloud.firestore.Firestore: A `Firestore Client`_. + google.cloud.firestore.Firestore: A `Firestore Client`_. Raises: - ValueError: If a project ID is not specified either via options, credentials or - environment variables, or if the specified project ID is not a valid string. + ValueError: If the specified database ID is not a valid string, or if a project ID is not + specified either via options, credentials or environment variables, or if the specified + project ID is not a valid string. - .. _Firestore Client: https://googlecloudplatform.github.io/google-cloud-python/latest\ - /firestore/client.html + .. _Firestore Client: https://cloud.google.com/python/docs/reference/firestore/latest/\ + google.cloud.firestore_v1.client.Client """ - fs_client = _utils.get_app_service(app, _FIRESTORE_ATTRIBUTE, _FirestoreClient.from_app) - return fs_client.get() - - -class _FirestoreClient: - """Holds a Google Cloud Firestore client instance.""" - - def __init__(self, credentials, project): - self._client = firestore.Client(credentials=credentials, project=project) - - def get(self): - return self._client - - @classmethod - def from_app(cls, app): - """Creates a new _FirestoreClient for the specified app.""" - credentials = app.credential.get_credential() - project = app.project_id - if not project: - raise ValueError( - 'Project ID is required to access Firestore. Either set the projectId option, ' - 'or use service account credentials. Alternatively, set the GOOGLE_CLOUD_PROJECT ' - 'environment variable.') - return _FirestoreClient(credentials, project) + # Validate database_id + if database_id is not None and not isinstance(database_id, str): + raise ValueError(f'database_id "{database_id}" must be a string or None.') + fs_service = _utils.get_app_service(app, _FIRESTORE_ATTRIBUTE, _FirestoreService) + return fs_service.get_client(database_id) + + +class _FirestoreService: + """Service that maintains a collection of firestore clients.""" + + def __init__(self, app: App) -> None: + self._app: App = app + self._clients: Dict[str, firestore.Client] = {} + + def get_client(self, database_id: Optional[str]) -> firestore.Client: + """Creates a client based on the database_id. These clients are cached.""" + database_id = database_id or DEFAULT_DATABASE + if database_id not in self._clients: + # Create a new client and cache it in _clients + credentials = self._app.credential.get_credential() + project = self._app.project_id + if not project: + raise ValueError( + 'Project ID is required to access Firestore. Either set the projectId option, ' + 'or use service account credentials. Alternatively, set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + + fs_client = firestore.Client( + credentials=credentials, project=project, database=database_id) + self._clients[database_id] = fs_client + + return self._clients[database_id] diff --git a/firebase_admin/firestore_async.py b/firebase_admin/firestore_async.py new file mode 100644 index 000000000..4a197e9df --- /dev/null +++ b/firebase_admin/firestore_async.py @@ -0,0 +1,92 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Cloud Firestore Async module. + +This module contains utilities for asynchronusly accessing the Google Cloud Firestore databases +associated with Firebase apps. This requires the ``google-cloud-firestore`` Python module. +""" + +from __future__ import annotations +from typing import Optional, Dict +from firebase_admin import App +from firebase_admin import _utils + +try: + from google.cloud import firestore + from google.cloud.firestore_v1.base_client import DEFAULT_DATABASE + existing = globals().keys() + for key, value in firestore.__dict__.items(): + if not key.startswith('_') and key not in existing: + globals()[key] = value +except ImportError as error: + raise ImportError('Failed to import the Cloud Firestore library for Python. Make sure ' + 'to install the "google-cloud-firestore" module.') from error + + +_FIRESTORE_ASYNC_ATTRIBUTE: str = '_firestore_async' + + +def client(app: Optional[App] = None, database_id: Optional[str] = None) -> firestore.AsyncClient: + """Returns an async client that can be used to interact with Google Cloud Firestore. + + Args: + app: An App instance (optional). + database_id: The database ID of the Google Cloud Firestore database to be used. + Defaults to the default Firestore database ID if not specified or an empty string + (optional). + + Returns: + google.cloud.firestore.Firestore_Async: A `Firestore Async Client`_. + + Raises: + ValueError: If the specified database ID is not a valid string, or if a project ID is not + specified either via options, credentials or environment variables, or if the specified + project ID is not a valid string. + + .. _Firestore Async Client: https://cloud.google.com/python/docs/reference/firestore/latest/\ + google.cloud.firestore_v1.async_client.AsyncClient + """ + # Validate database_id + if database_id is not None and not isinstance(database_id, str): + raise ValueError(f'database_id "{database_id}" must be a string or None.') + + fs_service = _utils.get_app_service(app, _FIRESTORE_ASYNC_ATTRIBUTE, _FirestoreAsyncService) + return fs_service.get_client(database_id) + +class _FirestoreAsyncService: + """Service that maintains a collection of firestore async clients.""" + + def __init__(self, app: App) -> None: + self._app: App = app + self._clients: Dict[str, firestore.AsyncClient] = {} + + def get_client(self, database_id: Optional[str]) -> firestore.AsyncClient: + """Creates an async client based on the database_id. These clients are cached.""" + database_id = database_id or DEFAULT_DATABASE + if database_id not in self._clients: + # Create a new client and cache it in _clients + credentials = self._app.credential.get_credential() + project = self._app.project_id + if not project: + raise ValueError( + 'Project ID is required to access Firestore. Either set the projectId option, ' + 'or use service account credentials. Alternatively, set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + + fs_client = firestore.AsyncClient( + credentials=credentials, project=project, database=database_id) + self._clients[database_id] = fs_client + + return self._clients[database_id] diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py new file mode 100644 index 000000000..66ba700b3 --- /dev/null +++ b/firebase_admin/functions.py @@ -0,0 +1,518 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase Functions module.""" + +from __future__ import annotations +from datetime import datetime, timedelta, timezone +from urllib import parse +import re +import os +import json +from base64 import b64encode +from typing import Any, Optional, Dict +from dataclasses import dataclass + +from google.auth.compute_engine import Credentials as ComputeEngineCredentials +from google.auth.credentials import TokenState +from google.auth.exceptions import RefreshError +from google.auth.transport import requests as google_auth_requests + +import requests +import firebase_admin +from firebase_admin import App +from firebase_admin import _http_client +from firebase_admin import _utils + +_FUNCTIONS_ATTRIBUTE = '_functions' + +__all__ = [ + 'TaskOptions', + + 'task_queue', +] + + +_CLOUD_TASKS_API_RESOURCE_PATH = \ + 'projects/{project_id}/locations/{location_id}/queues/{resource_id}/tasks' +_CLOUD_TASKS_API_URL_FORMAT = \ + 'https://cloudtasks.googleapis.com/v2/' + _CLOUD_TASKS_API_RESOURCE_PATH +_FIREBASE_FUNCTION_URL_FORMAT = \ + 'https://{location_id}-{project_id}.cloudfunctions.net/{resource_id}' +_EMULATOR_HOST_ENV_VAR = 'CLOUD_TASKS_EMULATOR_HOST' +_EMULATED_SERVICE_ACCOUNT_DEFAULT = 'emulated-service-acct@email.com' + +_FUNCTIONS_HEADERS = { + 'X-GOOG-API-FORMAT-VERSION': '2', + 'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}', +} + +# Default canonical location ID of the task queue. +_DEFAULT_LOCATION = 'us-central1' + +def _get_emulator_host() -> Optional[str]: + emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR) + if emulator_host: + if '//' in emulator_host: + raise ValueError( + f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' + '"host:port".') + return emulator_host + return None + + +def _get_functions_service(app) -> _FunctionsService: + return _utils.get_app_service(app, _FUNCTIONS_ATTRIBUTE, _FunctionsService) + +def task_queue( + function_name: str, + extension_id: Optional[str] = None, + app: Optional[App] = None + ) -> TaskQueue: + """Creates a reference to a TaskQueue for a given function name. + + The function name can be either: + 1. A fully qualified function resource name: + `projects/{project-id}/locations/{location-id}/functions/{function-name}` + + 2. A partial resource name with location and function name, in which case + the runtime project ID is used: + `locations/{location-id}/functions/{function-name}` + + 3. A partial function name, in which case the runtime project ID and the + default location, `us-central1`, is used: + `{function-name}` + + Args: + function_name: Name of the function. + extension_id: Firebase extension ID (optional). + app: An App instance (optional). + + Returns: + TaskQueue: A TaskQueue instance. + + Raises: + ValueError: If the input arguments are invalid. + """ + return _get_functions_service(app).task_queue(function_name, extension_id) + +class _FunctionsService: + """Service class that implements Firebase Functions functionality.""" + def __init__(self, app: App): + self._project_id = app.project_id + if not self._project_id: + raise ValueError( + 'Project ID is required to access the Cloud Functions service. Either set the ' + 'projectId option, or use service account credentials. Alternatively, set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + + self._emulator_host = _get_emulator_host() + if self._emulator_host: + self._credential = _utils.EmulatorAdminCredentials() + else: + self._credential = app.credential.get_credential() + + self._http_client = _http_client.JsonHttpClient(credential=self._credential) + + def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue: + """Creates a TaskQueue instance.""" + return TaskQueue( + function_name, extension_id, self._project_id, self._credential, self._http_client, + self._emulator_host) + + @classmethod + def handle_functions_error(cls, error: Any): + """Handles errors received from the Cloud Functions API.""" + + return _utils.handle_platform_error_from_requests(error) + +class TaskQueue: + """TaskQueue class that implements Firebase Cloud Tasks Queues functionality.""" + def __init__( + self, + function_name: str, + extension_id: Optional[str], + project_id, + credential, + http_client, + emulator_host: Optional[str] = None + ) -> None: + + # Validate function_name + _Validators.check_non_empty_string('function_name', function_name) + + self._project_id = project_id + self._credential = credential + self._http_client = http_client + self._emulator_host = emulator_host + self._function_name = function_name + self._extension_id = extension_id + # Parse resources from function_name + self._resource = self._parse_resource_name(self._function_name, 'functions') + + # Apply defaults and validate resource_id + self._resource.project_id = self._resource.project_id or self._project_id + self._resource.location_id = self._resource.location_id or _DEFAULT_LOCATION + _Validators.check_non_empty_string('resource.resource_id', self._resource.resource_id) + # Validate extension_id if provided and edit resources depending + if self._extension_id is not None: + _Validators.check_non_empty_string('extension_id', self._extension_id) + self._resource.resource_id = f'ext-{self._extension_id}-{self._resource.resource_id}' + + + def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: + """Creates a task and adds it to the queue. Tasks cannot be updated after creation. + + This action requires `cloudtasks.tasks.create` IAM permission on the service account. + + Args: + task_data: The data payload of the task. + opts: Options when enqueuing a new task (optional). + + Raises: + FirebaseError: If an error occurs while requesting the task to be queued by + the Cloud Functions service. + ValueError: If the input arguments are invalid. + + Returns: + str: The ID of the task relative to this queue. + """ + task = self._validate_task_options(task_data, self._resource, opts) + emulator_url = self._get_emulator_url(self._resource) + service_url = emulator_url or self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) + task_payload = self._update_task_payload(task, self._resource, self._extension_id) + try: + resp = self._http_client.body( + 'post', + url=service_url, + headers=_FUNCTIONS_HEADERS, + json={'task': task_payload.to_api_dict()} + ) + if self._is_emulated(): + # Emulator returns a response with format {task: {name: }} + # The task name also has an extra '/' at the start compared to prod + task_info = resp.get('task') or {} + task_name = task_info.get('name') + if task_name: + task_name = task_name[1:] + else: + # Production returns a response with format {name: } + task_name = resp.get('name') + task_resource = \ + self._parse_resource_name(task_name, f'queues/{self._resource.resource_id}/tasks') + return task_resource.resource_id + except requests.exceptions.RequestException as error: + raise _FunctionsService.handle_functions_error(error) + + def delete(self, task_id: str) -> None: + """Deletes an enqueued task if it has not yet started. + + This action requires `cloudtasks.tasks.delete` IAM permission on the service account. + + Args: + task_id: The ID of the task relative to this queue. + + Raises: + FirebaseError: If an error occurs while requesting the task to be deleted by + the Cloud Functions service. + ValueError: If the input arguments are invalid. + """ + _Validators.check_non_empty_string('task_id', task_id) + emulator_url = self._get_emulator_url(self._resource) + if emulator_url: + service_url = emulator_url + f'/{task_id}' + else: + service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') + try: + self._http_client.body( + 'delete', + url=service_url, + headers=_FUNCTIONS_HEADERS, + ) + except requests.exceptions.RequestException as error: + raise _FunctionsService.handle_functions_error(error) + + + def _parse_resource_name(self, resource_name: str, resource_id_key: str) -> Resource: + """Parses a full or partial resource path into a ``Resource``.""" + if '/' not in resource_name: + return Resource(resource_id=resource_name) + + reg = f'^(projects/([^/]+)/)?locations/([^/]+)/{resource_id_key}/([^/]+)$' + match = re.search(reg, resource_name) + if match is None: + raise ValueError('Invalid resource name format.') + return Resource(project_id=match[2], location_id=match[3], resource_id=match[4]) + + def _get_url(self, resource: Resource, url_format: str) -> str: + """Generates url path from a ``Resource`` and url format string.""" + return url_format.format( + project_id=resource.project_id, + location_id=resource.location_id, + resource_id=resource.resource_id) + + def _validate_task_options( + self, + data: Any, + resource: Resource, + opts: Optional[TaskOptions] = None + ) -> Task: + """Validate and create a Task from optional ``TaskOptions``.""" + task_http_request = { + 'url': '', + 'oidcToken': { + 'serviceAccountEmail': '' + }, + 'body': b64encode(json.dumps(data).encode()).decode(), + 'headers': { + 'Content-Type': 'application/json', + } + } + task = Task(http_request=task_http_request) + + if opts is not None: + if opts.headers is not None: + task.http_request['headers'] = {**task.http_request['headers'], **opts.headers} + if opts.schedule_time is not None and opts.schedule_delay_seconds is not None: + raise ValueError( + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.') + if opts.schedule_time is not None and opts.schedule_delay_seconds is None: + if not isinstance(opts.schedule_time, datetime): + raise ValueError('schedule_time should be UTC datetime.') + task.schedule_time = opts.schedule_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ') + if opts.schedule_delay_seconds is not None and opts.schedule_time is None: + if not isinstance(opts.schedule_delay_seconds, int) \ + or opts.schedule_delay_seconds < 0: + raise ValueError('schedule_delay_seconds should be positive int.') + schedule_time = ( + datetime.now(timezone.utc) + timedelta(seconds=opts.schedule_delay_seconds)) + task.schedule_time = schedule_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ') + if opts.dispatch_deadline_seconds is not None: + if not isinstance(opts.dispatch_deadline_seconds, int) \ + or opts.dispatch_deadline_seconds < 15 \ + or opts.dispatch_deadline_seconds > 1800: + raise ValueError( + 'dispatch_deadline_seconds should be int in the range of 15s to ' + '1800s (30 mins).') + task.dispatch_deadline = f'{opts.dispatch_deadline_seconds}s' + if opts.task_id is not None: + if not _Validators.is_task_id(opts.task_id): + raise ValueError( + 'task_id can contain only letters ([A-Za-z]), numbers ([0-9]), hyphens (-)' + ', or underscores (_). The maximum length is 500 characters.') + task.name = self._get_url( + resource, _CLOUD_TASKS_API_RESOURCE_PATH + f'/{opts.task_id}') + if opts.uri is not None: + if not _Validators.is_url(opts.uri): + raise ValueError( + 'uri must be a valid RFC3986 URI string using the https or http schema.') + task.http_request['url'] = opts.uri + return task + + def _update_task_payload(self, task: Task, resource: Resource, extension_id: str) -> Task: + """Prepares task to be sent with credentials.""" + # Get function url from task or generate from resources + if not _Validators.is_non_empty_string(task.http_request['url']): + if self._is_emulated(): + task.http_request['url'] = '' + else: + task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) + + # Refresh the credential to ensure all attributes (e.g. service_account_email, id_token) + # are populated, preventing cold start errors. + if self._credential.token_state != TokenState.FRESH: + try: + self._credential.refresh(google_auth_requests.Request()) + except RefreshError as err: + raise ValueError(f'Initial task payload credential refresh failed: {err}') from err + + # If extension id is provided, it implies that it is being run from a deployed extension. + # Meaning that it's credential should be a Compute Engine Credential. + if _Validators.is_non_empty_string(extension_id) and \ + isinstance(self._credential, ComputeEngineCredentials): + id_token = self._credential.token + task.http_request['headers'] = \ + {**task.http_request['headers'], 'Authorization': f'Bearer {id_token}'} + # Delete oidc token + del task.http_request['oidcToken'] + else: + try: + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': self._credential.service_account_email} + except AttributeError as error: + if self._is_emulated(): + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': _EMULATED_SERVICE_ACCOUNT_DEFAULT} + else: + raise ValueError( + 'Failed to determine service account. Initialize the SDK with service ' + 'account credentials or set service account ID as an app option.' + ) from error + return task + + def _get_emulator_url(self, resource: Resource): + if self._emulator_host: + emulator_url_format = f'http://{self._emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH + url = self._get_url(resource, emulator_url_format) + return url + return None + + def _is_emulated(self): + return self._emulator_host is not None + + +class _Validators: + """A collection of data validation utilities.""" + @classmethod + def check_non_empty_string(cls, label: str, value: Any): + """Checks if given value is a non-empty string and throws error if not.""" + if not isinstance(value, str): + raise ValueError(f'{label} "{value}" must be a string.') + if value == '': + raise ValueError(f'{label} "{value}" must be a non-empty string.') + + @classmethod + def is_non_empty_string(cls, value: Any): + """Checks if given value is a non-empty string and returns bool.""" + if not isinstance(value, str) or value == '': + return False + return True + + @classmethod + def is_task_id(cls, task_id: Any): + """Checks if given value is a valid task id.""" + reg = '^[A-Za-z0-9_-]+$' + if re.match(reg, task_id) is not None and len(task_id) <= 500: + return True + return False + + @classmethod + def is_url(cls, url: Any): + """Checks if given value is a valid url.""" + if not isinstance(url, str): + return False + try: + parsed = parse.urlparse(url) + if not parsed.netloc or parsed.scheme not in ['http', 'https']: + return False + return True + except Exception: # pylint: disable=broad-except + return False + + +@dataclass +class TaskOptions: + """Task Options that can be applied to a Task. + + Args: + schedule_delay_seconds: The number of seconds after the current time at which to attempt or + retry the task. Should only be set if ``schedule_time`` is not set. + + schedule_time: The time when the task is scheduled to be attempted or retried. Should only + be set if ``schedule_delay_seconds`` is not set. + + dispatch_deadline_seconds: The deadline for requests sent to the worker. If the worker does + not respond by this deadline then the request is cancelled and the attempt is marked as + a ``DEADLINE_EXCEEDED`` failure. Cloud Tasks will retry the task according to the + ``RetryConfig``. The default is 10 minutes. The deadline must be in the range of 15 + seconds and 30 minutes (1800 seconds). + + task_id: The ID to use for the enqueued task. If not provided, one will be automatically + generated. + + If provided, an explicitly specified task ID enables task de-duplication. + Task IDs should be strings that contain only letters ([A-Za-z]), numbers ([0-9]), + hyphens (-), and underscores (_) with a maximum length of 500 characters. If a task's + ID is identical to that of an existing task or a task that was deleted or executed + recently then the call will throw an error with code `functions/task-already-exists`. + Another task with the same ID can't be created for ~1hour after the original task was + deleted or executed. + + Because there is an extra lookup cost to identify duplicate task IDs, setting ID + significantly increases latency. + + Also, note that the infrastructure relies on an approximately uniform distribution + of task IDs to store and serve tasks efficiently. For this reason, using hashed strings + for the task ID or for the prefix of the task ID is recommended. Choosing task IDs that + are sequential or have sequential prefixes, for example using a timestamp, causes an + increase in latency and error rates in all task commands. + + Push IDs from the Firebase Realtime Database make poor IDs because they are based on + timestamps and will cause contention (slowdowns) in your task queue. Reversed push IDs + however form a perfect distribution and are an ideal key. To reverse a string in Python + use ``reversedString = someString[::-1]`` + + headers: HTTP request headers to include in the request to the task queue function. These + headers represent a subset of the headers that will accompany the task's HTTP request. + Some HTTP request headers will be ignored or replaced: `Authorization`, `Host`, + `Content-Length`, `User-Agent` and others cannot be overridden. + + A complete list of these ignored or replaced headers can be found in the following + definition of the HttpRequest.headers property: + https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#httprequest + + By default, Content-Type is set to 'application/json'. + + The size of the headers must be less than 80KB. + + uri: The full URL that the request will be sent to. Must be a valid RFC3986 https or + http URL. + """ + schedule_delay_seconds: Optional[int] = None + schedule_time: Optional[datetime] = None + dispatch_deadline_seconds: Optional[int] = None + task_id: Optional[str] = None + headers: Optional[Dict[str, str]] = None + uri: Optional[str] = None + +@dataclass +class Task: + """Contains the relevant fields for enqueueing tasks that trigger Cloud Functions. + + This is a limited subset of the Cloud Functions `Task` resource. See the following + page for definitions of this class's properties: + https://cloud.google.com/tasks/docs/reference/rest/v2/projects.locations.queues.tasks#resource:-task + + Args: + httpRequest: The request to be made by the task worker. + name: The name of the function. See the Cloud docs for the format of this property. + schedule_time: The time when the task is scheduled to be attempted or retried. + dispatch_deadline: The deadline for requests sent to the worker. + """ + http_request: Dict[str, Optional[str | dict]] + name: Optional[str] = None + schedule_time: Optional[str] = None + dispatch_deadline: Optional[str] = None + + def to_api_dict(self) -> dict: + """Converts the Task object to a dictionary suitable for the Cloud Tasks API.""" + return { + 'httpRequest': self.http_request, + 'name': self.name, + 'scheduleTime': self.schedule_time, + 'dispatchDeadline': self.dispatch_deadline, + } + +@dataclass +class Resource: + """Contains the parsed address of a resource. + + Args: + resource_id: The ID of the resource. + project_id: The project ID of the resource. + location_id: The location ID of the resource. + """ + resource_id: str + project_id: Optional[str] = None + location_id: Optional[str] = None diff --git a/firebase_admin/instance_id.py b/firebase_admin/instance_id.py index 604158d9c..812daf40b 100644 --- a/firebase_admin/instance_id.py +++ b/firebase_admin/instance_id.py @@ -81,7 +81,7 @@ def __init__(self, app): def delete_instance_id(self, instance_id): if not isinstance(instance_id, str) or not instance_id: raise ValueError('Instance ID must be a non-empty string.') - path = 'project/{0}/instanceId/{1}'.format(self._project_id, instance_id) + path = f'project/{self._project_id}/instanceId/{instance_id}' try: self._client.request('delete', path) except requests.exceptions.RequestException as error: @@ -94,6 +94,6 @@ def _extract_message(self, instance_id, error): status = error.response.status_code msg = self.error_codes.get(status) if msg: - return 'Instance ID "{0}": {1}'.format(instance_id, msg) + return f'Instance ID "{instance_id}": {msg}' - return 'Instance ID "{0}": {1}'.format(instance_id, error) + return f'Instance ID "{instance_id}": {error}' diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 217cf0a56..749044436 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -14,19 +14,26 @@ """Firebase Cloud Messaging module.""" +from __future__ import annotations +from typing import Any, Callable, Dict, List, Optional, cast +import concurrent.futures import json - -import googleapiclient -from googleapiclient import http -from googleapiclient import _auth +import asyncio +import logging import requests +import httpx import firebase_admin -from firebase_admin import _http_client -from firebase_admin import _messaging_encoder -from firebase_admin import _messaging_utils -from firebase_admin import _utils +from firebase_admin import ( + _http_client, + _messaging_encoder, + _messaging_utils, + _utils, + exceptions, + App +) +logger = logging.getLogger(__name__) _MESSAGING_ATTRIBUTE = '_messaging' @@ -60,8 +67,10 @@ 'WebpushNotificationAction', 'send', - 'send_all', - 'send_multicast', + 'send_each', + 'send_each_async', + 'send_each_for_multicast', + 'send_each_for_multicast_async', 'subscribe_to_topic', 'unsubscribe_from_topic', ] @@ -92,14 +101,14 @@ UnregisteredError = _messaging_utils.UnregisteredError -def _get_messaging_service(app): +def _get_messaging_service(app: Optional[App]) -> _MessagingService: return _utils.get_app_service(app, _MESSAGING_ATTRIBUTE, _MessagingService) -def send(message, dry_run=False, app=None): +def send(message: Message, dry_run: bool = False, app: Optional[App] = None) -> str: """Sends the given message via Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the - recipients. Instead FCM performs all the usual validations, and emulates the send operation. + recipients. Instead, FCM performs all the usual validations and emulates the send operation. Args: message: An instance of ``messaging.Message``. @@ -107,7 +116,7 @@ def send(message, dry_run=False, app=None): app: An App instance (optional). Returns: - string: A message ID string that uniquely identifies the sent the message. + string: A message ID string that uniquely identifies the sent message. Raises: FirebaseError: If an error occurs while sending the message to the FCM service. @@ -115,11 +124,39 @@ def send(message, dry_run=False, app=None): """ return _get_messaging_service(app).send(message, dry_run) -def send_all(messages, dry_run=False, app=None): - """Sends the given list of messages via Firebase Cloud Messaging as a single batch. +def send_each( + messages: List[Message], + dry_run: bool = False, + app: Optional[App] = None + ) -> BatchResponse: + """Sends each message in the given list via Firebase Cloud Messaging. + + If the ``dry_run`` mode is enabled, the message will not be actually delivered to the + recipients. Instead, FCM performs all the usual validations and emulates the send operation. + + Args: + messages: A list of ``messaging.Message`` instances. + dry_run: A boolean indicating whether to run the operation in dry run mode (optional). + app: An App instance (optional). + + Returns: + BatchResponse: A ``messaging.BatchResponse`` instance. + + Raises: + FirebaseError: If an error occurs while sending the message to the FCM service. + ValueError: If the input arguments are invalid. + """ + return _get_messaging_service(app).send_each(messages, dry_run) + +async def send_each_async( + messages: List[Message], + dry_run: bool = False, + app: Optional[App] = None + ) -> BatchResponse: + """Sends each message in the given list asynchronously via Firebase Cloud Messaging. If the ``dry_run`` mode is enabled, the message will not be actually delivered to the - recipients. Instead FCM performs all the usual validations, and emulates the send operation. + recipients. Instead, FCM performs all the usual validations and emulates the send operation. Args: messages: A list of ``messaging.Message`` instances. @@ -133,13 +170,49 @@ def send_all(messages, dry_run=False, app=None): FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ - return _get_messaging_service(app).send_all(messages, dry_run) + return await _get_messaging_service(app).send_each_async(messages, dry_run) + +async def send_each_for_multicast_async( + multicast_message: MulticastMessage, + dry_run: bool = False, + app: Optional[App] = None + ) -> BatchResponse: + """Sends the given mutlicast message to each token asynchronously via Firebase Cloud Messaging + (FCM). + + If the ``dry_run`` mode is enabled, the message will not be actually delivered to the + recipients. Instead, FCM performs all the usual validations and emulates the send operation. + + Args: + multicast_message: An instance of ``messaging.MulticastMessage``. + dry_run: A boolean indicating whether to run the operation in dry run mode (optional). + app: An App instance (optional). + + Returns: + BatchResponse: A ``messaging.BatchResponse`` instance. + + Raises: + FirebaseError: If an error occurs while sending the message to the FCM service. + ValueError: If the input arguments are invalid. + """ + if not isinstance(multicast_message, MulticastMessage): + raise ValueError('Message must be an instance of messaging.MulticastMessage class.') + messages = [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + token=token + ) for token in multicast_message.tokens] + return await _get_messaging_service(app).send_each_async(messages, dry_run) -def send_multicast(multicast_message, dry_run=False, app=None): - """Sends the given mutlicast message to all tokens via Firebase Cloud Messaging (FCM). +def send_each_for_multicast(multicast_message, dry_run=False, app=None): + """Sends the given mutlicast message to each token via Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the - recipients. Instead FCM performs all the usual validations, and emulates the send operation. + recipients. Instead, FCM performs all the usual validations and emulates the send operation. Args: multicast_message: An instance of ``messaging.MulticastMessage``. @@ -164,7 +237,7 @@ def send_multicast(multicast_message, dry_run=False, app=None): fcm_options=multicast_message.fcm_options, token=token ) for token in multicast_message.tokens] - return _get_messaging_service(app).send_all(messages, dry_run) + return _get_messaging_service(app).send_each(messages, dry_run) def subscribe_to_topic(tokens, topic, app=None): """Subscribes a list of registration tokens to an FCM topic. @@ -228,7 +301,7 @@ class TopicManagementResponse: def __init__(self, resp): if not isinstance(resp, dict) or 'results' not in resp: - raise ValueError('Unexpected topic management response: {0}.'.format(resp)) + raise ValueError(f'Unexpected topic management response: {resp}.') self._success_count = 0 self._failure_count = 0 self._errors = [] @@ -258,21 +331,21 @@ def errors(self): class BatchResponse: """The response received from a batch request to the FCM API.""" - def __init__(self, responses): + def __init__(self, responses: List[SendResponse]) -> None: self._responses = responses - self._success_count = len([resp for resp in responses if resp.success]) + self._success_count = sum(1 for resp in responses if resp.success) @property - def responses(self): + def responses(self) -> List[SendResponse]: """A list of ``messaging.SendResponse`` objects (possibly empty).""" return self._responses @property - def success_count(self): + def success_count(self) -> int: return self._success_count @property - def failure_count(self): + def failure_count(self) -> int: return len(self.responses) - self.success_count @@ -300,7 +373,6 @@ def exception(self): """A ``FirebaseError`` if an error occurs while sending the message to the FCM service.""" return self._exception - class _MessagingService: """Service class that implements Firebase Cloud Messaging (FCM) functionality.""" @@ -318,7 +390,7 @@ class _MessagingService: 'UNREGISTERED': UnregisteredError, } - def __init__(self, app): + def __init__(self, app: App) -> None: project_id = app.project_id if not project_id: raise ValueError( @@ -328,12 +400,13 @@ def __init__(self, app): self._fcm_url = _MessagingService.FCM_URL.format(project_id) self._fcm_headers = { 'X-GOOG-API-FORMAT-VERSION': '2', - 'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__), + 'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}', } timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) - self._client = _http_client.JsonHttpClient( - credential=app.credential.get_credential(), timeout=timeout) - self._transport = _auth.authorized_http(app.credential.get_credential()) + self._credential = app.credential.get_credential() + self._client = _http_client.JsonHttpClient(credential=self._credential, timeout=timeout) + self._async_client = _http_client.HttpxAsyncClient( + credential=self._credential, timeout=timeout) @classmethod def encode_message(cls, message): @@ -341,7 +414,7 @@ def encode_message(cls, message): raise ValueError('Message must be an instance of messaging.Message class.') return cls.JSON_ENCODER.default(message) - def send(self, message, dry_run=False): + def send(self, message: Message, dry_run: bool = False) -> str: """Sends the given message to FCM via the FCM v1 API.""" data = self._message_data(message, dry_run) try: @@ -353,45 +426,65 @@ def send(self, message, dry_run=False): ) except requests.exceptions.RequestException as error: raise self._handle_fcm_error(error) - else: - return resp['name'] + return cast(str, resp['name']) - def send_all(self, messages, dry_run=False): - """Sends the given messages to FCM via the batch API.""" + def send_each(self, messages: List[Message], dry_run: bool = False) -> BatchResponse: + """Sends the given messages to FCM via the FCM v1 API.""" if not isinstance(messages, list): raise ValueError('messages must be a list of messaging.Message instances.') if len(messages) > 500: raise ValueError('messages must not contain more than 500 elements.') - responses = [] - - def batch_callback(_, response, error): - exception = None - if error: - exception = self._handle_batch_error(error) - send_response = SendResponse(response, exception) - responses.append(send_response) - - batch = http.BatchHttpRequest( - callback=batch_callback, batch_uri=_MessagingService.FCM_BATCH_URL) - for message in messages: - body = json.dumps(self._message_data(message, dry_run)) - req = http.HttpRequest( - http=self._transport, - postproc=self._postproc, - uri=self._fcm_url, - method='POST', - body=body, - headers=self._fcm_headers - ) - batch.add(req) + def send_data(data): + try: + resp = self._client.body( + 'post', + url=self._fcm_url, + headers=self._fcm_headers, + json=data) + except requests.exceptions.RequestException as exception: + return SendResponse(resp=None, exception=self._handle_fcm_error(exception)) + return SendResponse(resp, exception=None) + + message_data = [self._message_data(message, dry_run) for message in messages] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=len(message_data)) as executor: + responses = list(executor.map(send_data, message_data)) + return BatchResponse(responses) + except Exception as error: + raise exceptions.UnknownError( + message=f'Unknown error while making remote service calls: {error}', + cause=error) + + async def send_each_async(self, messages: List[Message], dry_run: bool = True) -> BatchResponse: + """Sends the given messages to FCM via the FCM v1 API.""" + if not isinstance(messages, list): + raise ValueError('messages must be a list of messaging.Message instances.') + if len(messages) > 500: + raise ValueError('messages must not contain more than 500 elements.') + async def send_data(data): + try: + resp = await self._async_client.request( + 'post', + url=self._fcm_url, + headers=self._fcm_headers, + json=data) + except httpx.HTTPError as exception: + return SendResponse(resp=None, exception=self._handle_fcm_httpx_error(exception)) + # Catch errors caused by the requests library during authorization + except requests.exceptions.RequestException as exception: + return SendResponse(resp=None, exception=self._handle_fcm_error(exception)) + return SendResponse(resp.json(), exception=None) + + message_data = [self._message_data(message, dry_run) for message in messages] try: - batch.execute() - except googleapiclient.http.HttpError as error: - raise self._handle_batch_error(error) - else: + responses = await asyncio.gather(*[send_data(message) for message in message_data]) return BatchResponse(responses) + except Exception as error: + raise exceptions.UnknownError( + message=f'Unknown error while making remote service calls: {error}', + cause=error) def make_topic_management_request(self, tokens, topic, operation): """Invokes the IID service for topic management functionality.""" @@ -406,12 +499,12 @@ def make_topic_management_request(self, tokens, topic, operation): if not isinstance(topic, str) or not topic: raise ValueError('Topic must be a non-empty string.') if not topic.startswith('/topics/'): - topic = '/topics/{0}'.format(topic) + topic = f'/topics/{topic}' data = { 'to': topic, 'registration_tokens': tokens, } - url = '{0}/{1}'.format(_MessagingService.IID_URL, operation) + url = f'{_MessagingService.IID_URL}/{operation}' try: resp = self._client.body( 'post', @@ -421,8 +514,7 @@ def make_topic_management_request(self, tokens, topic, operation): ) except requests.exceptions.RequestException as error: raise self._handle_iid_error(error) - else: - return TopicManagementResponse(resp) + return TopicManagementResponse(resp) def _message_data(self, message, dry_run): data = {'message': _MessagingService.encode_message(message)} @@ -440,6 +532,11 @@ def _handle_fcm_error(self, error): return _utils.handle_platform_error_from_requests( error, _MessagingService._build_fcm_error_requests) + def _handle_fcm_httpx_error(self, error: httpx.HTTPError) -> exceptions.FirebaseError: + """Handles errors received from the FCM API.""" + return _utils.handle_platform_error_from_httpx( + error, _MessagingService._build_fcm_error_httpx) + def _handle_iid_error(self, error): """Handles errors received from the Instance ID API.""" if error.response is None: @@ -457,34 +554,49 @@ def _handle_iid_error(self, error): code = data.get('error') msg = None if code: - msg = 'Error while calling the IID service: {0}'.format(code) + msg = f'Error while calling the IID service: {code}' else: - msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( - error.response.status_code, error.response.content.decode()) + msg = ( + f'Unexpected HTTP response with status: {error.response.status_code}; body: ' + f'{error.response.content.decode()}' + ) return _utils.handle_requests_error(error, msg) - def _handle_batch_error(self, error): - """Handles errors received from the googleapiclient while making batch requests.""" - return _utils.handle_platform_error_from_googleapiclient( - error, _MessagingService._build_fcm_error_googleapiclient) + def close(self) -> None: + asyncio.run(self._async_client.aclose()) @classmethod def _build_fcm_error_requests(cls, error, message, error_dict): """Parses an error response from the FCM API and creates a FCM-specific exception if appropriate.""" exc_type = cls._build_fcm_error(error_dict) + # pylint: disable=not-callable return exc_type(message, cause=error, http_response=error.response) if exc_type else None @classmethod - def _build_fcm_error_googleapiclient(cls, error, message, error_dict, http_response): - """Parses an error response from the FCM API and creates a FCM-specific exception if + def _build_fcm_error_httpx( + cls, + error: httpx.HTTPError, + message: str, + error_dict: Optional[Dict[str, Any]] + ) -> Optional[exceptions.FirebaseError]: + """Parses a httpx error response from the FCM API and creates a FCM-specific exception if appropriate.""" exc_type = cls._build_fcm_error(error_dict) - return exc_type(message, cause=error, http_response=http_response) if exc_type else None + if isinstance(error, httpx.HTTPStatusError): + # pylint: disable=not-callable + return exc_type( + message, cause=error, http_response=error.response) if exc_type else None + # pylint: disable=not-callable + return exc_type(message, cause=error) if exc_type else None @classmethod - def _build_fcm_error(cls, error_dict): + def _build_fcm_error( + cls, + error_dict: Optional[Dict[str, Any]] + ) -> Optional[Callable[..., exceptions.FirebaseError]]: + """Parses an error response to determine the appropriate FCM-specific error type.""" if not error_dict: return None fcm_code = None @@ -492,4 +604,4 @@ def _build_fcm_error(cls, error_dict): if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError': fcm_code = detail.get('errorCode') break - return _MessagingService.FCM_ERROR_TYPES.get(fcm_code) + return _MessagingService.FCM_ERROR_TYPES.get(fcm_code) if fcm_code else None diff --git a/firebase_admin/ml.py b/firebase_admin/ml.py index 2613a3de3..3a77dd05f 100644 --- a/firebase_admin/ml.py +++ b/firebase_admin/ml.py @@ -32,14 +32,14 @@ from firebase_admin import _utils from firebase_admin import exceptions -# pylint: disable=import-error,no-name-in-module +# pylint: disable=import-error,no-member try: from firebase_admin import storage _GCS_ENABLED = True except ImportError: _GCS_ENABLED = False -# pylint: disable=import-error,no-name-in-module +# pylint: disable=import-error,no-member try: import tensorflow as tf _TF_ENABLED = True @@ -75,7 +75,7 @@ def _get_ml_service(app): def create_model(model, app=None): - """Creates a model in Firebase ML. + """Creates a model in the current Firebase project. Args: model: An ml.Model to create. @@ -89,7 +89,7 @@ def create_model(model, app=None): def update_model(model, app=None): - """Updates a model in Firebase ML. + """Updates a model's metadata or model file. Args: model: The ml.Model to update. @@ -103,7 +103,9 @@ def update_model(model, app=None): def publish_model(model_id, app=None): - """Publishes a model in Firebase ML. + """Publishes a Firebase ML model. + + A published model can be downloaded to client apps. Args: model_id: The id of the model to publish. @@ -117,7 +119,7 @@ def publish_model(model_id, app=None): def unpublish_model(model_id, app=None): - """Unpublishes a model in Firebase ML. + """Unpublishes a Firebase ML model. Args: model_id: The id of the model to unpublish. @@ -131,7 +133,7 @@ def unpublish_model(model_id, app=None): def get_model(model_id, app=None): - """Gets a model from Firebase ML. + """Gets the model specified by the given ID. Args: model_id: The id of the model to get. @@ -145,7 +147,7 @@ def get_model(model_id, app=None): def list_models(list_filter=None, page_size=None, page_token=None, app=None): - """Lists models from Firebase ML. + """Lists the current project's models. Args: list_filter: a list filter string such as ``tags:'tag_1'``. None will return all models. @@ -164,7 +166,7 @@ def list_models(list_filter=None, page_size=None, page_token=None, app=None): def delete_model(model_id, app=None): - """Deletes a model from Firebase ML. + """Deletes a model from the current project. Args: model_id: The id of the model you wish to delete. @@ -363,15 +365,10 @@ def __init__(self, model_source=None): def from_dict(cls, data): """Create an instance of the object from a dict.""" data_copy = dict(data) - model_source = None - gcs_tflite_uri = data_copy.pop('gcsTfliteUri', None) - if gcs_tflite_uri: - model_source = TFLiteGCSModelSource(gcs_tflite_uri=gcs_tflite_uri) - tflite_format = TFLiteFormat(model_source=model_source) + tflite_format = TFLiteFormat(model_source=cls._init_model_source(data_copy)) tflite_format._data = data_copy # pylint: disable=protected-access return tflite_format - def __eq__(self, other): if isinstance(other, self.__class__): # pylint: disable=protected-access @@ -381,6 +378,14 @@ def __eq__(self, other): def __ne__(self, other): return not self.__eq__(other) + @staticmethod + def _init_model_source(data): + """Initialize the ML model source.""" + gcs_tflite_uri = data.pop('gcsTfliteUri', None) + if gcs_tflite_uri: + return TFLiteGCSModelSource(gcs_tflite_uri=gcs_tflite_uri) + return None + @property def model_source(self): """The TF Lite model's location.""" @@ -502,8 +507,8 @@ def _assert_tf_enabled(): raise ImportError('Failed to import the tensorflow library for Python. Make sure ' 'to install the tensorflow module.') if not tf.version.VERSION.startswith('1.') and not tf.version.VERSION.startswith('2.'): - raise ImportError('Expected tensorflow version 1.x or 2.x, but found {0}' - .format(tf.version.VERSION)) + raise ImportError( + f'Expected tensorflow version 1.x or 2.x, but found {tf.version.VERSION}') @staticmethod def _tf_convert_from_saved_model(saved_model_dir): @@ -592,9 +597,8 @@ def as_dict(self, for_upload=False): return {'gcsTfliteUri': self._gcs_tflite_uri} - class ListModelsPage: - """Represents a page of models in a firebase project. + """Represents a page of models in a Firebase project. Provides methods for traversing the models included in this page, as well as retrieving subsequent pages of models. The iterator returned by @@ -672,7 +676,7 @@ def __init__(self, current_page): self._current_page = current_page self._index = 0 - def next(self): + def __next__(self): if self._index == len(self._current_page.models): if self._current_page.has_next_page: self._current_page = self._current_page.get_next_page() @@ -683,9 +687,6 @@ def next(self): return result raise StopIteration - def __next__(self): - return self.next() - def __iter__(self): return self @@ -759,8 +760,8 @@ def _validate_page_size(page_size): # Specifically type() to disallow boolean which is a subtype of int raise TypeError('Page size must be a number or None.') if page_size < 1 or page_size > _MAX_PAGE_SIZE: - raise ValueError('Page size must be a positive integer between ' - '1 and {0}'.format(_MAX_PAGE_SIZE)) + raise ValueError( + f'Page size must be a positive integer between 1 and {_MAX_PAGE_SIZE}') def _validate_page_token(page_token): @@ -785,7 +786,7 @@ def __init__(self, app): 'projectId option, or use service account credentials.') self._project_url = _MLService.PROJECT_URL.format(self._project_id) ml_headers = { - 'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__), + 'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}', } self._client = _http_client.JsonHttpClient( credential=app.credential.get_credential(), @@ -882,9 +883,9 @@ def create_model(self, model): def update_model(self, model, update_mask=None): _validate_model(model, update_mask) - path = 'models/{0}'.format(model.model_id) + path = f'models/{model.model_id}' if update_mask is not None: - path = path + '?updateMask={0}'.format(update_mask) + path = path + f'?updateMask={update_mask}' try: return self.handle_operation( self._client.body('patch', url=path, json=model.as_dict(for_upload=True))) @@ -893,7 +894,7 @@ def update_model(self, model, update_mask=None): def set_published(self, model_id, publish): _validate_model_id(model_id) - model_name = 'projects/{0}/models/{1}'.format(self._project_id, model_id) + model_name = f'projects/{self._project_id}/models/{model_id}' model = Model.from_dict({ 'name': model_name, 'state': { @@ -905,7 +906,7 @@ def set_published(self, model_id, publish): def get_model(self, model_id): _validate_model_id(model_id) try: - return self._client.body('get', url='models/{0}'.format(model_id)) + return self._client.body('get', url=f'models/{model_id}') except requests.exceptions.RequestException as error: raise _utils.handle_platform_error_from_requests(error) @@ -933,6 +934,6 @@ def list_models(self, list_filter, page_size, page_token): def delete_model(self, model_id): _validate_model_id(model_id) try: - self._client.body('delete', url='models/{0}'.format(model_id)) + self._client.body('delete', url=f'models/{model_id}') except requests.exceptions.RequestException as error: raise _utils.handle_platform_error_from_requests(error) diff --git a/firebase_admin/project_management.py b/firebase_admin/project_management.py index ed292b80f..73c100d3a 100644 --- a/firebase_admin/project_management.py +++ b/firebase_admin/project_management.py @@ -118,13 +118,13 @@ def create_ios_app(bundle_id, display_name=None, app=None): def _check_is_string_or_none(obj, field_name): if obj is None or isinstance(obj, str): return obj - raise ValueError('{0} must be a string.'.format(field_name)) + raise ValueError(f'{field_name} must be a string.') def _check_is_nonempty_string(obj, field_name): if isinstance(obj, str) and obj: return obj - raise ValueError('{0} must be a non-empty string.'.format(field_name)) + raise ValueError(f'{field_name} must be a non-empty string.') def _check_is_nonempty_string_or_none(obj, field_name): @@ -135,7 +135,7 @@ def _check_is_nonempty_string_or_none(obj, field_name): def _check_not_none(obj, field_name): if obj is None: - raise ValueError('{0} cannot be None.'.format(field_name)) + raise ValueError(f'{field_name} cannot be None.') return obj @@ -338,7 +338,7 @@ class AndroidAppMetadata(_AppMetadata): def __init__(self, package_name, name, app_id, display_name, project_id): """Clients should not instantiate this class directly.""" - super(AndroidAppMetadata, self).__init__(name, app_id, display_name, project_id) + super().__init__(name, app_id, display_name, project_id) self._package_name = _check_is_nonempty_string(package_name, 'package_name') @property @@ -347,7 +347,7 @@ def package_name(self): return self._package_name def __eq__(self, other): - return (super(AndroidAppMetadata, self).__eq__(other) and + return (super().__eq__(other) and self.package_name == other.package_name) def __ne__(self, other): @@ -363,7 +363,7 @@ class IOSAppMetadata(_AppMetadata): def __init__(self, bundle_id, name, app_id, display_name, project_id): """Clients should not instantiate this class directly.""" - super(IOSAppMetadata, self).__init__(name, app_id, display_name, project_id) + super().__init__(name, app_id, display_name, project_id) self._bundle_id = _check_is_nonempty_string(bundle_id, 'bundle_id') @property @@ -372,7 +372,7 @@ def bundle_id(self): return self._bundle_id def __eq__(self, other): - return super(IOSAppMetadata, self).__eq__(other) and self.bundle_id == other.bundle_id + return super().__eq__(other) and self.bundle_id == other.bundle_id def __ne__(self, other): return not self.__eq__(other) @@ -477,7 +477,7 @@ def __init__(self, app): 'set the projectId option, or use service account credentials. Alternatively, set ' 'the GOOGLE_CLOUD_PROJECT environment variable.') self._project_id = project_id - version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) + version_header = f'Python/Admin/{firebase_admin.__version__}' timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) self._client = _http_client.JsonHttpClient( credential=app.credential.get_credential(), @@ -502,7 +502,7 @@ def get_ios_app_metadata(self, app_id): def _get_app_metadata(self, platform_resource_name, identifier_name, metadata_class, app_id): """Retrieves detailed information about an Android or iOS app.""" _check_is_nonempty_string(app_id, 'app_id') - path = '/v1beta1/projects/-/{0}/{1}'.format(platform_resource_name, app_id) + path = f'/v1beta1/projects/-/{platform_resource_name}/{app_id}' response = self._make_request('get', path) return metadata_class( response[identifier_name], @@ -525,8 +525,7 @@ def set_ios_app_display_name(self, app_id, new_display_name): def _set_display_name(self, app_id, new_display_name, platform_resource_name): """Sets the display name of an Android or iOS app.""" - path = '/v1beta1/projects/-/{0}/{1}?updateMask=displayName'.format( - platform_resource_name, app_id) + path = f'/v1beta1/projects/-/{platform_resource_name}/{app_id}?updateMask=displayName' request_body = {'displayName': new_display_name} self._make_request('patch', path, json=request_body) @@ -542,10 +541,10 @@ def list_ios_apps(self): def _list_apps(self, platform_resource_name, app_class): """Lists all the Android or iOS apps within the Firebase project.""" - path = '/v1beta1/projects/{0}/{1}?pageSize={2}'.format( - self._project_id, - platform_resource_name, - _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE) + path = ( + f'/v1beta1/projects/{self._project_id}/{platform_resource_name}?pageSize=' + f'{_ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE}' + ) response = self._make_request('get', path) apps_list = [] while True: @@ -557,11 +556,11 @@ def _list_apps(self, platform_resource_name, app_class): if not next_page_token: break # Retrieve the next page of apps. - path = '/v1beta1/projects/{0}/{1}?pageToken={2}&pageSize={3}'.format( - self._project_id, - platform_resource_name, - next_page_token, - _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE) + path = ( + f'/v1beta1/projects/{self._project_id}/{platform_resource_name}' + f'?pageToken={next_page_token}' + f'&pageSize={_ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE}' + ) response = self._make_request('get', path) return apps_list @@ -590,7 +589,7 @@ def _create_app( app_class): """Creates an Android or iOS app.""" _check_is_string_or_none(display_name, 'display_name') - path = '/v1beta1/projects/{0}/{1}'.format(self._project_id, platform_resource_name) + path = f'/v1beta1/projects/{self._project_id}/{platform_resource_name}' request_body = {identifier_name: identifier} if display_name: request_body['displayName'] = display_name @@ -606,7 +605,7 @@ def _poll_app_creation(self, operation_name): _ProjectManagementService.POLL_EXPONENTIAL_BACKOFF_FACTOR, current_attempt) wait_time_seconds = delay_factor * _ProjectManagementService.POLL_BASE_WAIT_TIME_SECONDS time.sleep(wait_time_seconds) - path = '/v1/{0}'.format(operation_name) + path = f'/v1/{operation_name}' poll_response, http_response = self._body_and_response('get', path) done = poll_response.get('done') if done: @@ -629,20 +628,20 @@ def get_ios_app_config(self, app_id): platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, app_id=app_id) def _get_app_config(self, platform_resource_name, app_id): - path = '/v1beta1/projects/-/{0}/{1}/config'.format(platform_resource_name, app_id) + path = f'/v1beta1/projects/-/{platform_resource_name}/{app_id}/config' response = self._make_request('get', path) # In Python 2.7, the base64 module works with strings, while in Python 3, it works with # bytes objects. This line works in both versions. return base64.standard_b64decode(response['configFileContents']).decode(encoding='utf-8') def get_sha_certificates(self, app_id): - path = '/v1beta1/projects/-/androidApps/{0}/sha'.format(app_id) + path = f'/v1beta1/projects/-/androidApps/{app_id}/sha' response = self._make_request('get', path) cert_list = response.get('certificates') or [] return [SHACertificate(sha_hash=cert['shaHash'], name=cert['name']) for cert in cert_list] def add_sha_certificate(self, app_id, certificate_to_add): - path = '/v1beta1/projects/-/androidApps/{0}/sha'.format(app_id) + path = f'/v1beta1/projects/-/androidApps/{app_id}/sha' sha_hash = _check_not_none(certificate_to_add, 'certificate_to_add').sha_hash cert_type = certificate_to_add.cert_type request_body = {'shaHash': sha_hash, 'certType': cert_type} @@ -650,7 +649,7 @@ def add_sha_certificate(self, app_id, certificate_to_add): def delete_sha_certificate(self, certificate_to_delete): name = _check_not_none(certificate_to_delete, 'certificate_to_delete').name - path = '/v1beta1/{0}'.format(name) + path = f'/v1beta1/{name}' self._make_request('delete', path) def _make_request(self, method, url, json=None): diff --git a/firebase_admin/remote_config.py b/firebase_admin/remote_config.py new file mode 100644 index 000000000..880804d3d --- /dev/null +++ b/firebase_admin/remote_config.py @@ -0,0 +1,762 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase Remote Config Module. +This module has required APIs for the clients to use Firebase Remote Config with python. +""" + +import asyncio +import json +import logging +import threading +from typing import Dict, Optional, Literal, Union, Any +from enum import Enum +import re +import hashlib +import requests +from firebase_admin import App, _http_client, _utils +import firebase_admin + +# Set up logging (you can customize the level and output) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +_REMOTE_CONFIG_ATTRIBUTE = '_remoteconfig' +MAX_CONDITION_RECURSION_DEPTH = 10 +ValueSource = Literal['default', 'remote', 'static'] # Define the ValueSource type + +class PercentConditionOperator(Enum): + """Enum representing the available operators for percent conditions. + """ + LESS_OR_EQUAL = "LESS_OR_EQUAL" + GREATER_THAN = "GREATER_THAN" + BETWEEN = "BETWEEN" + UNKNOWN = "UNKNOWN" + +class CustomSignalOperator(Enum): + """Enum representing the available operators for custom signal conditions. + """ + STRING_CONTAINS = "STRING_CONTAINS" + STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN" + STRING_EXACTLY_MATCHES = "STRING_EXACTLY_MATCHES" + STRING_CONTAINS_REGEX = "STRING_CONTAINS_REGEX" + NUMERIC_LESS_THAN = "NUMERIC_LESS_THAN" + NUMERIC_LESS_EQUAL = "NUMERIC_LESS_EQUAL" + NUMERIC_EQUAL = "NUMERIC_EQUAL" + NUMERIC_NOT_EQUAL = "NUMERIC_NOT_EQUAL" + NUMERIC_GREATER_THAN = "NUMERIC_GREATER_THAN" + NUMERIC_GREATER_EQUAL = "NUMERIC_GREATER_EQUAL" + SEMANTIC_VERSION_LESS_THAN = "SEMANTIC_VERSION_LESS_THAN" + SEMANTIC_VERSION_LESS_EQUAL = "SEMANTIC_VERSION_LESS_EQUAL" + SEMANTIC_VERSION_EQUAL = "SEMANTIC_VERSION_EQUAL" + SEMANTIC_VERSION_NOT_EQUAL = "SEMANTIC_VERSION_NOT_EQUAL" + SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN" + SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL" + UNKNOWN = "UNKNOWN" + +class _ServerTemplateData: + """Parses, validates and encapsulates template data and metadata.""" + def __init__(self, template_data): + """Initializes a new ServerTemplateData instance. + + Args: + template_data: The data to be parsed for getting the parameters and conditions. + + Raises: + ValueError: If the template data is not valid. + """ + if 'parameters' in template_data: + if template_data['parameters'] is not None: + self._parameters = template_data['parameters'] + else: + raise ValueError('Remote Config parameters must be a non-null object') + else: + self._parameters = {} + + if 'conditions' in template_data: + if template_data['conditions'] is not None: + self._conditions = template_data['conditions'] + else: + raise ValueError('Remote Config conditions must be a non-null object') + else: + self._conditions = [] + + self._version = '' + if 'version' in template_data: + self._version = template_data['version'] + + self._etag = '' + if 'etag' in template_data and isinstance(template_data['etag'], str): + self._etag = template_data['etag'] + + self._template_data_json = json.dumps(template_data) + + @property + def parameters(self): + return self._parameters + + @property + def etag(self): + return self._etag + + @property + def version(self): + return self._version + + @property + def conditions(self): + return self._conditions + + @property + def template_data_json(self): + return self._template_data_json + + +class ServerTemplate: + """Represents a Server Template with implementations for loading and evaluating the template.""" + def __init__(self, app: App = None, default_config: Optional[Dict[str, str]] = None): + """Initializes a ServerTemplate instance. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + """ + self._rc_service = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + # This gets set when the template is + # fetched from RC servers via the load API, or via the set API. + self._cache = None + self._stringified_default_config: Dict[str, str] = {} + self._lock = threading.RLock() + + # RC stores all remote values as string, but it's more intuitive + # to declare default values with specific types, so this converts + # the external declaration to an internal string representation. + if default_config is not None: + for key in default_config: + self._stringified_default_config[key] = str(default_config[key]) + + async def load(self): + """Fetches the server template and caches the data.""" + rc_server_template = await self._rc_service.get_server_template() + with self._lock: + self._cache = rc_server_template + + def evaluate(self, context: Optional[Dict[str, Union[str, int]]] = None) -> 'ServerConfig': + """Evaluates the cached server template to produce a ServerConfig. + + Args: + context: A dictionary of values to use for evaluating conditions. + + Returns: + A ServerConfig object. + Raises: + ValueError: If the input arguments are invalid. + """ + # Logic to process the cached template into a ServerConfig here. + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling evaluate().""") + context = context or {} + config_values = {} + + with self._lock: + template_conditions = self._cache.conditions + template_parameters = self._cache.parameters + + # Initializes config Value objects with default values. + if self._stringified_default_config is not None: + for key, value in self._stringified_default_config.items(): + config_values[key] = _Value('default', value) + self._evaluator = _ConditionEvaluator(template_conditions, + template_parameters, context, + config_values) + return ServerConfig(config_values=self._evaluator.evaluate()) + + def set(self, template_data_json: str): + """Updates the cache to store the given template is of type ServerTemplateData. + + Args: + template_data_json: A json string representing ServerTemplateData to be cached. + """ + template_data_map = json.loads(template_data_json) + template_data = _ServerTemplateData(template_data_map) + + with self._lock: + self._cache = template_data + + def to_json(self): + """Provides the server template in a JSON format to be used for initialization later.""" + if not self._cache: + raise ValueError("""No Remote Config Server template in cache. + Call load() before calling toJSON().""") + with self._lock: + template_json = self._cache.template_data_json + return template_json + + +class ServerConfig: + """Represents a Remote Config Server Side Config.""" + def __init__(self, config_values): + self._config_values = config_values # dictionary of param key to values + + def get_boolean(self, key): + """Returns the value as a boolean.""" + return self._get_value(key).as_boolean() + + def get_string(self, key): + """Returns the value as a string.""" + return self._get_value(key).as_string() + + def get_int(self, key): + """Returns the value as an integer.""" + return self._get_value(key).as_int() + + def get_float(self, key): + """Returns the value as a float.""" + return self._get_value(key).as_float() + + def get_value_source(self, key): + """Returns the source of the value.""" + return self._get_value(key).get_source() + + def _get_value(self, key): + return self._config_values.get(key, _Value('static')) + + +class _RemoteConfigService: + """Internal class that facilitates sending requests to the Firebase Remote + Config backend API. + """ + def __init__(self, app): + """Initialize a JsonHttpClient with necessary inputs. + + Args: + app: App instance to be used for fetching app specific details required + for initializing the http client. + """ + remote_config_base_url = 'https://firebaseremoteconfig.googleapis.com' + self._project_id = app.project_id + app_credential = app.credential.get_credential() + rc_headers = { + 'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}', } + timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) + + self._client = _http_client.JsonHttpClient(credential=app_credential, + base_url=remote_config_base_url, + headers=rc_headers, timeout=timeout) + + async def get_server_template(self): + """Requests for a server template and converts the response to an instance of + ServerTemplateData for storing the template parameters and conditions.""" + try: + loop = asyncio.get_event_loop() + headers, template_data = await loop.run_in_executor(None, + self._client.headers_and_body, + 'get', self._get_url()) + except requests.exceptions.RequestException as error: + raise self._handle_remote_config_error(error) + template_data['etag'] = headers.get('etag') + return _ServerTemplateData(template_data) + + def _get_url(self): + """Returns project prefix for url, in the format of /v1/projects/${projectId}""" + return f"/v1/projects/{self._project_id}/namespaces/firebase-server/serverRemoteConfig" + + @classmethod + def _handle_remote_config_error(cls, error: Any): + """Handles errors received from the Cloud Functions API.""" + return _utils.handle_platform_error_from_requests(error) + + +class _ConditionEvaluator: + """Internal class that facilitates sending requests to the Firebase Remote + Config backend API.""" + def __init__(self, conditions, parameters, context, config_values): + self._context = context + self._conditions = conditions + self._parameters = parameters + self._config_values = config_values + + def evaluate(self): + """Internal function that evaluates the cached server template to produce + a ServerConfig""" + evaluated_conditions = self.evaluate_conditions(self._conditions, self._context) + + # Overlays config Value objects derived by evaluating the template. + if self._parameters: + for key, parameter in self._parameters.items(): + conditional_values = parameter.get('conditionalValues', {}) + default_value = parameter.get('defaultValue', {}) + parameter_value_wrapper = None + # Iterates in order over condition list. If there is a value associated + # with a condition, this checks if the condition is true. + if evaluated_conditions: + for condition_name, condition_evaluation in evaluated_conditions.items(): + if condition_name in conditional_values and condition_evaluation: + parameter_value_wrapper = conditional_values[condition_name] + break + + if parameter_value_wrapper and parameter_value_wrapper.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + + if parameter_value_wrapper: + parameter_value = parameter_value_wrapper.get('value') + self._config_values[key] = _Value('remote', parameter_value) + continue + + if not default_value: + logger.warning("No default value found for key '%s'", key) + continue + + if default_value.get('useInAppDefault'): + logger.info("Using in-app default value for key '%s'", key) + continue + self._config_values[key] = _Value('remote', default_value.get('value')) + return self._config_values + + def evaluate_conditions(self, conditions, context)-> Dict[str, bool]: + """Evaluates a list of conditions and returns a dictionary of results. + + Args: + conditions: A list of NamedCondition objects. + context: An EvaluationContext object. + + Returns: + A dictionary that maps condition names to boolean evaluation results. + """ + evaluated_conditions = {} + for condition in conditions: + evaluated_conditions[condition.get('name')] = self.evaluate_condition( + condition.get('condition'), context + ) + return evaluated_conditions + + def evaluate_condition(self, condition, context, + nesting_level: int = 0) -> bool: + """Recursively evaluates a condition. + + Args: + condition: The condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + The boolean result of the condition evaluation. + """ + if nesting_level >= MAX_CONDITION_RECURSION_DEPTH: + logger.warning("Maximum condition recursion depth exceeded.") + return False + if condition.get('orCondition') is not None: + return self.evaluate_or_condition(condition.get('orCondition'), + context, nesting_level + 1) + if condition.get('andCondition') is not None: + return self.evaluate_and_condition(condition.get('andCondition'), + context, nesting_level + 1) + if condition.get('true') is not None: + return True + if condition.get('false') is not None: + return False + if condition.get('percent') is not None: + return self.evaluate_percent_condition(condition.get('percent'), context) + if condition.get('customSignal') is not None: + return self.evaluate_custom_signal_condition(condition.get('customSignal'), context) + logger.warning("Unknown condition type encountered.") + return False + + def evaluate_or_condition(self, or_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an OR condition. + + Args: + or_condition: The OR condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if any of the subconditions are true, False otherwise. + """ + sub_conditions = or_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if result: + return True + return False + + def evaluate_and_condition(self, and_condition, + context, + nesting_level: int = 0) -> bool: + """Evaluates an AND condition. + + Args: + and_condition: The AND condition to evaluate. + context: An EvaluationContext object. + nesting_level: The current recursion depth. + + Returns: + True if all of the subconditions are met; False otherwise. + """ + sub_conditions = and_condition.get('conditions') or [] + for sub_condition in sub_conditions: + result = self.evaluate_condition(sub_condition, context, nesting_level + 1) + if not result: + return False + return True + + def evaluate_percent_condition(self, percent_condition, + context) -> bool: + """Evaluates a percent condition. + + Args: + percent_condition: The percent condition to evaluate. + context: An EvaluationContext object. + + Returns: + True if the condition is met, False otherwise. + """ + if not context.get('randomization_id'): + logger.warning("Missing randomization_id in context for evaluating percent condition.") + return False + + seed = percent_condition.get('seed') + percent_operator = percent_condition.get('percentOperator') + micro_percent = percent_condition.get('microPercent') + micro_percent_range = percent_condition.get('microPercentRange') + if not percent_operator: + logger.warning("Missing percent operator for percent condition.") + return False + if micro_percent_range: + norm_percent_upper_bound = micro_percent_range.get('microPercentUpperBound') or 0 + norm_percent_lower_bound = micro_percent_range.get('microPercentLowerBound') or 0 + else: + norm_percent_upper_bound = 0 + norm_percent_lower_bound = 0 + if micro_percent: + norm_micro_percent = micro_percent + else: + norm_micro_percent = 0 + seed_prefix = f"{seed}." if seed else "" + string_to_hash = f"{seed_prefix}{context.get('randomization_id')}" + + hash64 = self.hash_seeded_randomization_id(string_to_hash) + instance_micro_percentile = hash64 % (100 * 1000000) + if percent_operator == PercentConditionOperator.LESS_OR_EQUAL.value: + return instance_micro_percentile <= norm_micro_percent + if percent_operator == PercentConditionOperator.GREATER_THAN.value: + return instance_micro_percentile > norm_micro_percent + if percent_operator == PercentConditionOperator.BETWEEN.value: + return norm_percent_lower_bound < instance_micro_percentile <= norm_percent_upper_bound + logger.warning("Unknown percent operator: %s", percent_operator) + return False + def hash_seeded_randomization_id(self, seeded_randomization_id: str) -> int: + """Hashes a seeded randomization ID. + + Args: + seeded_randomization_id: The seeded randomization ID to hash. + + Returns: + The hashed value. + """ + hash_object = hashlib.sha256() + hash_object.update(seeded_randomization_id.encode('utf-8')) + hash64 = hash_object.hexdigest() + return abs(int(hash64, 16)) + + def evaluate_custom_signal_condition(self, custom_signal_condition, + context) -> bool: + """Evaluates a custom signal condition. + + Args: + custom_signal_condition: The custom signal condition to evaluate. + context: An EvaluationContext object. + + Returns: + True if the condition is met, False otherwise. + """ + custom_signal_operator = custom_signal_condition.get('customSignalOperator') or {} + custom_signal_key = custom_signal_condition.get('customSignalKey') or {} + target_custom_signal_values = ( + custom_signal_condition.get('targetCustomSignalValues') or {}) + + if not all([custom_signal_operator, custom_signal_key, target_custom_signal_values]): + logger.warning("Missing operator, key, or target values for custom signal condition.") + return False + + if not target_custom_signal_values: + return False + actual_custom_signal_value = context.get(custom_signal_key) or {} + + if not actual_custom_signal_value: + logger.debug("Custom signal value not found in context: %s", custom_signal_key) + return False + + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS.value: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_DOES_NOT_CONTAIN.value: + return not self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target in actual) + if custom_signal_operator == CustomSignalOperator.STRING_EXACTLY_MATCHES.value: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + lambda target, actual: target.strip() == actual.strip()) + if custom_signal_operator == CustomSignalOperator.STRING_CONTAINS_REGEX.value: + return self._compare_strings(target_custom_signal_values, + actual_custom_signal_value, + re.search) + + # For numeric operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_THAN.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_LESS_EQUAL.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_EQUAL.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_NOT_EQUAL.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_THAN.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.NUMERIC_GREATER_EQUAL.value: + return self._compare_numbers(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + + # For semantic operators only one target value is allowed. + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r < 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r <= 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r == 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r != 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r > 0) + if custom_signal_operator == CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL.value: + return self._compare_semantic_versions(custom_signal_key, + target_custom_signal_values[0], + actual_custom_signal_value, + lambda r: r >= 0) + logger.warning("Unknown custom signal operator: %s", custom_signal_operator) + return False + + def _compare_strings(self, target_values, actual_value, predicate_fn) -> bool: + """Compares the actual string value of a signal against a list of target values. + + Args: + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes two string arguments (target and actual) + and returns a boolean indicating whether + the target matches the actual value. + + Returns: + bool: True if the predicate function returns True for any target value in the list, + False otherwise. + """ + + for target in target_values: + if predicate_fn(target, str(actual_value)): + return True + return False + + def _compare_numbers(self, custom_signal_key, target_value, actual_value, predicate_fn) -> bool: + try: + target = float(target_value) + actual = float(actual_value) + result = -1 if actual < target else 1 if actual > target else 0 + return predicate_fn(result) + except ValueError: + logger.warning("Invalid numeric value for comparison for custom signal key %s.", + custom_signal_key) + return False + + def _compare_semantic_versions(self, custom_signal_key, + target_value, actual_value, predicate_fn) -> bool: + """Compares the actual semantic version value of a signal against a target value. + Calls the predicate function with -1, 0, 1 if actual is less than, equal to, + or greater than target. + + Args: + custom_signal_key: The custom signal for which the evaluation is being performed. + target_values: A list of target string values. + actual_value: The actual value to compare, which can be a string or number. + predicate_fn: A function that takes an integer (-1, 0, or 1) and returns a boolean. + + Returns: + bool: True if the predicate function returns True for the result of the comparison, + False otherwise. + """ + return self._compare_versions(custom_signal_key, str(actual_value), + str(target_value), predicate_fn) + + def _compare_versions(self, custom_signal_key, + sem_version_1, sem_version_2, predicate_fn) -> bool: + """Compares two semantic version strings. + + Args: + custom_signal_key: The custom singal for which the evaluation is being performed. + sem_version_1: The first semantic version string. + sem_version_2: The second semantic version string. + predicate_fn: A function that takes an integer and returns a boolean. + + Returns: + bool: The result of the predicate function. + """ + try: + v1_parts = [int(part) for part in sem_version_1.split('.')] + v2_parts = [int(part) for part in sem_version_2.split('.')] + max_length = max(len(v1_parts), len(v2_parts)) + v1_parts.extend([0] * (max_length - len(v1_parts))) + v2_parts.extend([0] * (max_length - len(v2_parts))) + + for part1, part2 in zip(v1_parts, v2_parts): + if any((part1 < 0, part2 < 0)): + raise ValueError + if part1 < part2: + return predicate_fn(-1) + if part1 > part2: + return predicate_fn(1) + return predicate_fn(0) + except ValueError: + logger.warning( + "Invalid semantic version format for comparison for custom signal key %s.", + custom_signal_key) + return False + +async def get_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None): + """Initializes a new ServerTemplate instance and fetches the server template. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + + Returns: + ServerTemplate: An object having the cached server template to be used for evaluation. + """ + template = init_server_template(app=app, default_config=default_config) + await template.load() + return template + +def init_server_template(app: App = None, default_config: Optional[Dict[str, str]] = None, + template_data_json: Optional[str] = None): + """Initializes a new ServerTemplate instance. + + Args: + app: App instance to be used. This is optional and the default app instance will + be used if not present. + default_config: The default config to be used in the evaluated config. + template_data_json: An optional template data JSON to be set on initialization. + + Returns: + ServerTemplate: A new ServerTemplate instance initialized with an optional + template and config. + """ + template = ServerTemplate(app=app, default_config=default_config) + if template_data_json is not None: + template.set(template_data_json) + return template + +class _Value: + """Represents a value fetched from Remote Config. + """ + DEFAULT_VALUE_FOR_BOOLEAN = False + DEFAULT_VALUE_FOR_STRING = '' + DEFAULT_VALUE_FOR_INTEGER = 0 + DEFAULT_VALUE_FOR_FLOAT_NUMBER = 0.0 + BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on'] + + def __init__(self, source: ValueSource, value: str = DEFAULT_VALUE_FOR_STRING): + """Initializes a Value instance. + + Args: + source: The source of the value (e.g., 'default', 'remote', 'static'). + "static" indicates the value was defined by a static constant. + "default" indicates the value was defined by default config. + "remote" indicates the value was defined by config produced by evaluating a template. + value: The string value. + """ + self.source = source + self.value = value + + def as_string(self) -> str: + """Returns the value as a string.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_STRING + return str(self.value) + + def as_boolean(self) -> bool: + """Returns the value as a boolean.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_BOOLEAN + return str(self.value).lower() in self.BOOLEAN_TRUTHY_VALUES + + def as_int(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_INTEGER + try: + return int(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_INTEGER + + def as_float(self) -> float: + """Returns the value as a number.""" + if self.source == 'static': + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER + try: + return float(self.value) + except ValueError: + return self.DEFAULT_VALUE_FOR_FLOAT_NUMBER + + def get_source(self) -> ValueSource: + """Returns the source of the value.""" + return self.source diff --git a/firebase_admin/storage.py b/firebase_admin/storage.py index 16f48e273..d2f004be6 100644 --- a/firebase_admin/storage.py +++ b/firebase_admin/storage.py @@ -21,16 +21,16 @@ # pylint: disable=import-error,no-name-in-module try: from google.cloud import storage -except ImportError: +except ImportError as exception: raise ImportError('Failed to import the Cloud Storage library for Python. Make sure ' - 'to install the "google-cloud-storage" module.') + 'to install the "google-cloud-storage" module.') from exception from firebase_admin import _utils _STORAGE_ATTRIBUTE = '_storage' -def bucket(name=None, app=None): +def bucket(name=None, app=None) -> storage.Bucket: """Returns a handle to a Google Cloud Storage bucket. If the name argument is not provided, uses the 'storageBucket' option specified when @@ -55,8 +55,13 @@ def bucket(name=None, app=None): class _StorageClient: """Holds a Google Cloud Storage client instance.""" + STORAGE_HEADERS = { + 'x-goog-api-client': _utils.get_metrics_header(), + } + def __init__(self, credentials, project, default_bucket): - self._client = storage.Client(credentials=credentials, project=project) + self._client = storage.Client( + credentials=credentials, project=project, extra_headers=self.STORAGE_HEADERS) self._default_bucket = default_bucket @classmethod @@ -77,6 +82,6 @@ def bucket(self, name=None): 'name explicitly when calling the storage.bucket() function.') if not bucket_name or not isinstance(bucket_name, str): raise ValueError( - 'Invalid storage bucket name: "{0}". Bucket name must be a non-empty ' - 'string.'.format(bucket_name)) + f'Invalid storage bucket name: "{bucket_name}". Bucket name must be a non-empty ' + 'string.') return self._client.bucket(bucket_name) diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index 396a819fb..9e713d988 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -205,7 +205,7 @@ class Tenant: def __init__(self, data): if not isinstance(data, dict): - raise ValueError('Invalid data argument in Tenant constructor: {0}'.format(data)) + raise ValueError(f'Invalid data argument in Tenant constructor: {data}') if not 'name' in data: raise ValueError('Tenant response missing required keys.') @@ -232,12 +232,12 @@ def enable_email_link_sign_in(self): class _TenantManagementService: """Firebase tenant management service.""" - TENANT_MGT_URL = 'https://identitytoolkit.googleapis.com/v2beta1' + TENANT_MGT_URL = 'https://identitytoolkit.googleapis.com/v2' def __init__(self, app): credential = app.credential.get_credential() - version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) - base_url = '{0}/projects/{1}'.format(self.TENANT_MGT_URL, app.project_id) + version_header = f'Python/Admin/{firebase_admin.__version__}' + base_url = f'{self.TENANT_MGT_URL}/projects/{app.project_id}' self.app = app self.client = _http_client.JsonHttpClient( credential=credential, base_url=base_url, headers={'X-Client-Version': version_header}) @@ -248,7 +248,7 @@ def auth_for_tenant(self, tenant_id): """Gets an Auth Client instance scoped to the given tenant ID.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError( - 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) + f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') with self.lock: if tenant_id in self.tenant_clients: @@ -262,14 +262,13 @@ def get_tenant(self, tenant_id): """Gets the tenant corresponding to the given ``tenant_id``.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError( - 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) + f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') try: - body = self.client.body('get', '/tenants/{0}'.format(tenant_id)) + body = self.client.body('get', f'/tenants/{tenant_id}') except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) - else: - return Tenant(body) + return Tenant(body) def create_tenant( self, display_name, allow_password_sign_up=None, enable_email_link_sign_in=None): @@ -287,8 +286,7 @@ def create_tenant( body = self.client.body('post', '/tenants', json=payload) except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) - else: - return Tenant(body) + return Tenant(body) def update_tenant( self, tenant_id, display_name=None, allow_password_sign_up=None, @@ -310,24 +308,23 @@ def update_tenant( if not payload: raise ValueError('At least one parameter must be specified for update.') - url = '/tenants/{0}'.format(tenant_id) + url = f'/tenants/{tenant_id}' update_mask = ','.join(_auth_utils.build_update_mask(payload)) - params = 'updateMask={0}'.format(update_mask) + params = f'updateMask={update_mask}' try: body = self.client.body('patch', url, json=payload, params=params) except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) - else: - return Tenant(body) + return Tenant(body) def delete_tenant(self, tenant_id): """Deletes the tenant corresponding to the given ``tenant_id``.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError( - 'Invalid tenant ID: {0}. Tenant ID must be a non-empty string.'.format(tenant_id)) + f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') try: - self.client.request('delete', '/tenants/{0}'.format(tenant_id)) + self.client.request('delete', f'/tenants/{tenant_id}') except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) @@ -341,7 +338,7 @@ def list_tenants(self, page_token=None, max_results=_MAX_LIST_TENANTS_RESULTS): if max_results < 1 or max_results > _MAX_LIST_TENANTS_RESULTS: raise ValueError( 'Max results must be a positive integer less than or equal to ' - '{0}.'.format(_MAX_LIST_TENANTS_RESULTS)) + f'{_MAX_LIST_TENANTS_RESULTS}.') payload = {'pageSize': max_results} if page_token: @@ -417,7 +414,7 @@ def __init__(self, current_page): self._current_page = current_page self._index = 0 - def next(self): + def __next__(self): if self._index == len(self._current_page.tenants): if self._current_page.has_next_page: self._current_page = self._current_page.get_next_page() @@ -428,9 +425,6 @@ def next(self): return result raise StopIteration - def __next__(self): - return self.next() - def __iter__(self): return self diff --git a/integration/conftest.py b/integration/conftest.py index 169e02d5b..ebaf9297a 100644 --- a/integration/conftest.py +++ b/integration/conftest.py @@ -36,7 +36,7 @@ def _get_cert_path(request): def integration_conf(request): cert_path = _get_cert_path(request) - with open(cert_path) as cert: + with open(cert_path, encoding='utf-8') as cert: project_id = json.load(cert).get('project_id') if not project_id: raise ValueError('Failed to determine project ID from service account certificate.') @@ -57,8 +57,8 @@ def default_app(request): """ cred, project_id = integration_conf(request) ops = { - 'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id), - 'storageBucket' : '{0}.appspot.com'.format(project_id) + 'databaseURL' : f'https://{project_id}.firebaseio.com', + 'storageBucket' : f'{project_id}.appspot.com' } return firebase_admin.initialize_app(cred, ops) @@ -68,5 +68,5 @@ def api_key(request): if not path: raise ValueError('API key file not specified. Make sure to specify the "--apikey" ' 'command-line option.') - with open(path) as keyfile: + with open(path, encoding='utf-8') as keyfile: return keyfile.read().strip() diff --git a/integration/emulators/.gitignore b/integration/emulators/.gitignore new file mode 100644 index 000000000..b17f63107 --- /dev/null +++ b/integration/emulators/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/integration/emulators/firebase.json b/integration/emulators/firebase.json new file mode 100644 index 000000000..a7b727c4d --- /dev/null +++ b/integration/emulators/firebase.json @@ -0,0 +1,29 @@ +{ + "emulators": { + "tasks": { + "port": 9499 + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true, + "functions": { + "port": 5001 + } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "venv", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "runtime": "python313" + } + ] +} diff --git a/integration/emulators/functions/.gitignore b/integration/emulators/functions/.gitignore new file mode 100644 index 000000000..1609bab70 --- /dev/null +++ b/integration/emulators/functions/.gitignore @@ -0,0 +1,6 @@ +# Python bytecode +__pycache__/ + +# Python virtual environment +venv/ +*.local diff --git a/integration/emulators/functions/main.py b/integration/emulators/functions/main.py new file mode 100644 index 000000000..6cd2c5766 --- /dev/null +++ b/integration/emulators/functions/main.py @@ -0,0 +1,7 @@ +from firebase_functions import tasks_fn + +@tasks_fn.on_task_dispatched() +def testTaskQueue(req: tasks_fn.CallableRequest) -> None: + """Handles tasks from the task queue.""" + print(f"Received task with data: {req.data}") + return diff --git a/integration/emulators/functions/requirements.txt b/integration/emulators/functions/requirements.txt new file mode 100644 index 000000000..6bbab42f8 --- /dev/null +++ b/integration/emulators/functions/requirements.txt @@ -0,0 +1 @@ +firebase_functions~=0.4.1 diff --git a/integration/test_auth.py b/integration/test_auth.py index 26cf53d20..b36063d19 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -30,6 +30,7 @@ import firebase_admin from firebase_admin import auth from firebase_admin import credentials +from firebase_admin._http_client import DEFAULT_TIMEOUT_SECONDS as timeout _verify_token_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken' @@ -67,14 +68,14 @@ def _sign_in(custom_token, api_key): body = {'token' : custom_token.decode(), 'returnSecureToken' : True} params = {'key' : api_key} - resp = requests.request('post', _verify_token_url, params=params, json=body) + resp = requests.request('post', _verify_token_url, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('idToken') def _sign_in_with_password(email, password, api_key): body = {'email': email, 'password': password, 'returnSecureToken': True} params = {'key' : api_key} - resp = requests.request('post', _verify_password_url, params=params, json=body) + resp = requests.request('post', _verify_password_url, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('idToken') @@ -84,7 +85,7 @@ def _random_string(length=10): def _random_id(): random_id = str(uuid.uuid4()).lower().replace('-', '') - email = 'test{0}@example.{1}.com'.format(random_id[:12], random_id[12:]) + email = f'test{random_id[:12]}@example.{random_id[12:]}.com' return random_id, email def _random_phone(): @@ -93,21 +94,21 @@ def _random_phone(): def _reset_password(oob_code, new_password, api_key): body = {'oobCode': oob_code, 'newPassword': new_password} params = {'key' : api_key} - resp = requests.request('post', _password_reset_url, params=params, json=body) + resp = requests.request('post', _password_reset_url, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('email') def _verify_email(oob_code, api_key): body = {'oobCode': oob_code} params = {'key' : api_key} - resp = requests.request('post', _verify_email_url, params=params, json=body) + resp = requests.request('post', _verify_email_url, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('email') def _sign_in_with_email_link(email, oob_code, api_key): body = {'oobCode': oob_code, 'email': email} params = {'key' : api_key} - resp = requests.request('post', _email_sign_in_url, params=params, json=body) + resp = requests.request('post', _email_sign_in_url, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('idToken') @@ -360,7 +361,18 @@ def test_last_refresh_timestamp(new_user_with_params: auth.UserRecord, api_key): # login to cause the last_refresh_timestamp to be set _sign_in_with_password(new_user_with_params.email, 'secret', api_key) - new_user_with_params = auth.get_user(new_user_with_params.uid) + + # Attempt to retrieve the user 3 times (with a small delay between each + # attempt). Occassionally, this call retrieves the user data without the + # lastLoginTime/lastRefreshTime set; possibly because it's hitting a + # different server than the login request uses. + user_record = None + for iteration in range(0, 3): + user_record = auth.get_user(new_user_with_params.uid) + if user_record.user_metadata.last_refresh_timestamp is not None: + break + + time.sleep(2 ** iteration) # Ensure the last refresh time occurred at approximately 'now'. (With a # tolerance of up to 1 minute; we ideally want to ensure that any timezone @@ -369,7 +381,7 @@ def test_last_refresh_timestamp(new_user_with_params: auth.UserRecord, api_key): millis_per_second = 1000 millis_per_minute = millis_per_second * 60 - last_refresh_timestamp = new_user_with_params.user_metadata.last_refresh_timestamp + last_refresh_timestamp = user_record.user_metadata.last_refresh_timestamp assert last_refresh_timestamp == pytest.approx( time.time()*millis_per_second, 1*millis_per_minute) @@ -485,6 +497,14 @@ def test_disable_user(new_user_with_params): assert user.disabled is True assert len(user.provider_data) == 1 +def test_remove_provider(new_user_with_provider): + provider_ids = [provider.provider_id for provider in new_user_with_provider.provider_data] + assert 'google.com' in provider_ids + user = auth.update_user(new_user_with_provider.uid, providers_to_delete=['google.com']) + assert user.uid == new_user_with_provider.uid + new_provider_ids = [provider.provider_id for provider in user.provider_data] + assert 'google.com' not in new_provider_ids + def test_delete_user(): user = auth.create_user() auth.delete_user(user.uid) @@ -498,7 +518,7 @@ def test_delete_multiple_users(self): uid2 = auth.create_user(disabled=False).uid uid3 = auth.create_user(disabled=True).uid - delete_users_result = auth.delete_users([uid1, uid2, uid3]) + delete_users_result = self._slow_delete_users(auth, [uid1, uid2, uid3]) assert delete_users_result.success_count == 3 assert delete_users_result.failure_count == 0 assert len(delete_users_result.errors) == 0 @@ -510,16 +530,22 @@ def test_delete_multiple_users(self): def test_is_idempotent(self): uid = auth.create_user().uid - delete_users_result = auth.delete_users([uid]) + delete_users_result = self._slow_delete_users(auth, [uid]) assert delete_users_result.success_count == 1 assert delete_users_result.failure_count == 0 # Delete the user again, ensuring that everything still counts as a # success. - delete_users_result = auth.delete_users([uid]) + delete_users_result = self._slow_delete_users(auth, [uid]) assert delete_users_result.success_count == 1 assert delete_users_result.failure_count == 0 + def _slow_delete_users(self, auth, uids): + """The batchDelete endpoint has a rate limit of 1 QPS. Use this test + helper to ensure you don't exceed the quota.""" + time.sleep(1) + return auth.delete_users(uids) + def test_revoke_refresh_tokens(new_user): user = auth.get_user(new_user.uid) @@ -552,6 +578,24 @@ def test_verify_id_token_revoked(new_user, api_key): claims = auth.verify_id_token(id_token, check_revoked=True) assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp +def test_verify_id_token_disabled(new_user, api_key): + custom_token = auth.create_custom_token(new_user.uid) + id_token = _sign_in(custom_token, api_key) + claims = auth.verify_id_token(id_token, check_revoked=True) + + # Disable the user record. + auth.update_user(new_user.uid, disabled=True) + # Verify the ID token without checking revocation. This should + # not raise. + claims = auth.verify_id_token(id_token, check_revoked=False) + assert claims['sub'] == new_user.uid + + # Verify the ID token while checking revocation. This should + # raise an exception. + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_id_token(id_token, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + def test_verify_session_cookie_revoked(new_user, api_key): custom_token = auth.create_custom_token(new_user.uid) id_token = _sign_in(custom_token, api_key) @@ -574,6 +618,25 @@ def test_verify_session_cookie_revoked(new_user, api_key): claims = auth.verify_session_cookie(session_cookie, check_revoked=True) assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp + +def test_verify_session_cookie_disabled(new_user, api_key): + custom_token = auth.create_custom_token(new_user.uid) + id_token = _sign_in(custom_token, api_key) + session_cookie = auth.create_session_cookie(id_token, expires_in=datetime.timedelta(days=1)) + + # Disable the user record. + auth.update_user(new_user.uid, disabled=True) + # Verify the session cookie without checking revocation. This should + # not raise. + claims = auth.verify_session_cookie(session_cookie, check_revoked=False) + assert claims['sub'] == new_user.uid + + # Verify the session cookie while checking revocation. This should + # raise an exception. + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_session_cookie(session_cookie, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + def test_import_users(): uid, email = _random_id() user = auth.ImportUserRecord(uid=uid, email=email) @@ -661,6 +724,19 @@ def test_email_sign_in_with_settings(new_user_email_unverified, api_key): assert id_token is not None and len(id_token) > 0 assert auth.get_user(new_user_email_unverified.uid).email_verified +def test_auth_error_parse(new_user_email_unverified): + action_code_settings = auth.ActionCodeSettings( + ACTION_LINK_CONTINUE_URL, handle_code_in_app=True, link_domain="cool.link") + with pytest.raises(auth.InvalidHostingLinkDomainError) as excinfo: + auth.generate_sign_in_with_email_link(new_user_email_unverified.email, + action_code_settings=action_code_settings) + assert str(excinfo.value) == ('The provided hosting link domain is not configured in Firebase ' + 'Hosting or is not owned by the current project ' + '(INVALID_HOSTING_LINK_DOMAIN). The provided hosting link ' + 'domain is not configured in Firebase Hosting or is not owned ' + 'by the current project. This cannot be a default hosting domain ' + '(web.app or firebaseapp.com).') + @pytest.fixture(scope='module') def oidc_provider(): @@ -675,6 +751,9 @@ def test_create_oidc_provider_config(oidc_provider): assert oidc_provider.issuer == 'https://oidc.com/issuer' assert oidc_provider.display_name == 'OIDC_DISPLAY_NAME' assert oidc_provider.enabled is True + assert oidc_provider.id_token_response_type is True + assert oidc_provider.code_response_type is False + assert oidc_provider.client_secret is None def test_get_oidc_provider_config(oidc_provider): @@ -685,6 +764,9 @@ def test_get_oidc_provider_config(oidc_provider): assert provider_config.issuer == 'https://oidc.com/issuer' assert provider_config.display_name == 'OIDC_DISPLAY_NAME' assert provider_config.enabled is True + assert provider_config.id_token_response_type is True + assert provider_config.code_response_type is False + assert provider_config.client_secret is None def test_list_oidc_provider_configs(oidc_provider): @@ -706,11 +788,17 @@ def test_update_oidc_provider_config(): client_id='UPDATED_OIDC_CLIENT_ID', issuer='https://oidc.com/updated_issuer', display_name='UPDATED_OIDC_DISPLAY_NAME', - enabled=False) + enabled=False, + client_secret='CLIENT_SECRET', + id_token_response_type=False, + code_response_type=True) assert provider_config.client_id == 'UPDATED_OIDC_CLIENT_ID' assert provider_config.issuer == 'https://oidc.com/updated_issuer' assert provider_config.display_name == 'UPDATED_OIDC_DISPLAY_NAME' assert provider_config.enabled is False + assert provider_config.id_token_response_type is False + assert provider_config.code_response_type is True + assert provider_config.client_secret == 'CLIENT_SECRET' finally: auth.delete_oidc_provider_config(provider_config.provider_id) @@ -796,17 +884,19 @@ def test_delete_saml_provider_config(): def _create_oidc_provider_config(): - provider_id = 'oidc.{0}'.format(_random_string()) + provider_id = f'oidc.{_random_string()}' return auth.create_oidc_provider_config( provider_id=provider_id, client_id='OIDC_CLIENT_ID', issuer='https://oidc.com/issuer', display_name='OIDC_DISPLAY_NAME', - enabled=True) + enabled=True, + id_token_response_type=True, + code_response_type=False) def _create_saml_provider_config(): - provider_id = 'saml.{0}'.format(_random_string()) + provider_id = f'saml.{_random_string()}' return auth.create_saml_provider_config( provider_id=provider_id, idp_entity_id='IDP_ENTITY_ID', diff --git a/integration/test_db.py b/integration/test_db.py index c448436d6..1ceb0b992 100644 --- a/integration/test_db.py +++ b/integration/test_db.py @@ -16,6 +16,7 @@ import collections import json import os +import time import pytest @@ -38,7 +39,7 @@ def integration_conf(request): def app(request): cred, project_id = integration_conf(request) ops = { - 'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id), + 'databaseURL' : f'https://{project_id}.firebaseio.com', } return firebase_admin.initialize_app(cred, ops, name='integration-db') @@ -52,7 +53,7 @@ def default_app(): @pytest.fixture(scope='module') def update_rules(app): - with open(testutils.resource_filename('dinosaurs_index.json')) as rules_file: + with open(testutils.resource_filename('dinosaurs_index.json'), encoding='utf-8') as rules_file: new_rules = json.load(rules_file) client = db.reference('', app)._client rules = client.body('get', '/.settings/rules.json', params='format=strict') @@ -63,7 +64,7 @@ def update_rules(app): @pytest.fixture(scope='module') def testdata(): - with open(testutils.resource_filename('dinosaurs.json')) as dino_file: + with open(testutils.resource_filename('dinosaurs.json'), encoding='utf-8') as dino_file: return json.load(dino_file) @pytest.fixture(scope='module') @@ -194,8 +195,8 @@ def test_update_nested_children(self, testref): edward = python.child('users').push({'name' : 'Edward Cope', 'since' : 1800}) jack = python.child('users').push({'name' : 'Jack Horner', 'since' : 1940}) delta = { - '{0}/since'.format(edward.key) : 1840, - '{0}/since'.format(jack.key) : 1946 + f'{edward.key}/since' : 1840, + f'{jack.key}/since' : 1946 } python.child('users').update(delta) assert edward.get() == {'name' : 'Edward Cope', 'since' : 1840} @@ -245,6 +246,37 @@ def test_delete(self, testref): ref.delete() assert ref.get() is None +class TestListenOperations: + """Test cases for listening to changes to node values.""" + + def test_listen(self, testref): + self.events = [] + def callback(event): + self.events.append(event) + + python = testref.parent + registration = python.listen(callback) + try: + ref = python.child('users').push() + assert ref.path == '/_adminsdk/python/users/' + ref.key + assert ref.get() == '' + + self.wait_for(self.events, count=2) + assert len(self.events) == 2 + + assert self.events[1].event_type == 'put' + assert self.events[1].path == '/users/' + ref.key + assert self.events[1].data == '' + finally: + registration.close() + + @classmethod + def wait_for(cls, events, count=1, timeout_seconds=5): + must_end = time.time() + timeout_seconds + while time.time() < must_end: + if len(events) >= count: + return + raise pytest.fail('Timed out while waiting for events') class TestAdvancedQueries: """Test cases for advanced interactions via the db.Query interface.""" @@ -331,7 +363,7 @@ def override_app(request, update_rules): del update_rules cred, project_id = integration_conf(request) ops = { - 'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id), + 'databaseURL' : f'https://{project_id}.firebaseio.com', 'databaseAuthVariableOverride' : {'uid' : 'user1'} } app = firebase_admin.initialize_app(cred, ops, 'db-override') @@ -343,7 +375,7 @@ def none_override_app(request, update_rules): del update_rules cred, project_id = integration_conf(request) ops = { - 'databaseURL' : 'https://{0}.firebaseio.com'.format(project_id), + 'databaseURL' : f'https://{project_id}.firebaseio.com', 'databaseAuthVariableOverride' : None } app = firebase_admin.initialize_app(cred, ops, 'db-none-override') diff --git a/integration/test_firestore.py b/integration/test_firestore.py index 2bc3d1931..96cdd3fb1 100644 --- a/integration/test_firestore.py +++ b/integration/test_firestore.py @@ -17,12 +17,26 @@ from firebase_admin import firestore +_CITY = { + 'name': 'Mountain View', + 'country': 'USA', + 'population': 77846, + 'capital': False + } + +_MOVIE = { + 'Name': 'Interstellar', + 'Year': 2014, + 'Runtime': '2h 49m', + 'Academy Award Winner': True + } + def test_firestore(): client = firestore.client() expected = { - 'name': u'Mountain View', - 'country': u'USA', + 'name': 'Mountain View', + 'country': 'USA', 'population': 77846, 'capital': False } @@ -35,10 +49,51 @@ def test_firestore(): doc.delete() assert doc.get().exists is False +def test_firestore_explicit_database_id(): + client = firestore.client(database_id='testing-database') + expected = _CITY + doc = client.collection('cities').document() + doc.set(expected) + + data = doc.get() + assert data.to_dict() == expected + + doc.delete() + data = doc.get() + assert data.exists is False + +def test_firestore_multi_db(): + city_client = firestore.client() + movie_client = firestore.client(database_id='testing-database') + + expected_city = _CITY + expected_movie = _MOVIE + + city_doc = city_client.collection('cities').document() + movie_doc = movie_client.collection('movies').document() + + city_doc.set(expected_city) + movie_doc.set(expected_movie) + + city_data = city_doc.get() + movie_data = movie_doc.get() + + assert city_data.to_dict() == expected_city + assert movie_data.to_dict() == expected_movie + + city_doc.delete() + movie_doc.delete() + + city_data = city_doc.get() + movie_data = movie_doc.get() + + assert city_data.exists is False + assert movie_data.exists is False + def test_server_timestamp(): client = firestore.client() expected = { - 'name': u'Mountain View', + 'name': 'Mountain View', 'timestamp': firestore.SERVER_TIMESTAMP # pylint: disable=no-member } doc = client.collection('cities').document() diff --git a/integration/test_firestore_async.py b/integration/test_firestore_async.py new file mode 100644 index 000000000..e899f25b2 --- /dev/null +++ b/integration/test_firestore_async.py @@ -0,0 +1,114 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for firebase_admin.firestore_async module.""" +import asyncio +import datetime +import pytest + +from firebase_admin import firestore_async + +_CITY = { + 'name': 'Mountain View', + 'country': 'USA', + 'population': 77846, + 'capital': False + } + +_MOVIE = { + 'Name': 'Interstellar', + 'Year': 2014, + 'Runtime': '2h 49m', + 'Academy Award Winner': True + } + + +@pytest.mark.asyncio(loop_scope="session") +async def test_firestore_async(): + client = firestore_async.client() + expected = _CITY + doc = client.collection('cities').document() + await doc.set(expected) + + data = await doc.get() + assert data.to_dict() == expected + + await doc.delete() + data = await doc.get() + assert data.exists is False + +@pytest.mark.asyncio(loop_scope="session") +async def test_firestore_async_explicit_database_id(): + client = firestore_async.client(database_id='testing-database') + expected = _CITY + doc = client.collection('cities').document() + await doc.set(expected) + + data = await doc.get() + assert data.to_dict() == expected + + await doc.delete() + data = await doc.get() + assert data.exists is False + +@pytest.mark.asyncio(loop_scope="session") +async def test_firestore_async_multi_db(): + city_client = firestore_async.client() + movie_client = firestore_async.client(database_id='testing-database') + + expected_city = _CITY + expected_movie = _MOVIE + + city_doc = city_client.collection('cities').document() + movie_doc = movie_client.collection('movies').document() + + await asyncio.gather( + city_doc.set(expected_city), + movie_doc.set(expected_movie) + ) + + data = await asyncio.gather( + city_doc.get(), + movie_doc.get() + ) + + assert data[0].to_dict() == expected_city + assert data[1].to_dict() == expected_movie + + await asyncio.gather( + city_doc.delete(), + movie_doc.delete() + ) + + data = await asyncio.gather( + city_doc.get(), + movie_doc.get() + ) + assert data[0].exists is False + assert data[1].exists is False + +@pytest.mark.asyncio(loop_scope="session") +async def test_server_timestamp(): + client = firestore_async.client() + expected = { + 'name': 'Mountain View', + 'timestamp': firestore_async.SERVER_TIMESTAMP # pylint: disable=no-member + } + doc = client.collection('cities').document() + await doc.set(expected) + + data = await doc.get() + data = data.to_dict() + assert isinstance(data['timestamp'], datetime.datetime) + await doc.delete() diff --git a/integration/test_functions.py b/integration/test_functions.py new file mode 100644 index 000000000..fc972f9e5 --- /dev/null +++ b/integration/test_functions.py @@ -0,0 +1,88 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for firebase_admin.functions module.""" + +import os +import pytest + +import firebase_admin +from firebase_admin import functions +from firebase_admin import _utils +from integration import conftest + + +_DEFAULT_DATA = {'data': {'city': 'Seattle'}} +def integration_conf(request): + host_override = os.environ.get('CLOUD_TASKS_EMULATOR_HOST') + if host_override: + return _utils.EmulatorAdminCredentials(), 'fake-project-id' + + return conftest.integration_conf(request) + +@pytest.fixture(scope='module') +def app(request): + cred, project_id = integration_conf(request) + return firebase_admin.initialize_app( + cred, options={'projectId': project_id}, name='integration-functions') + +@pytest.fixture(scope='module', autouse=True) +def default_app(): + # Overwrites the default_app fixture in conftest.py. + # This test suite should not use the default app. Use the app fixture instead. + pass + + +class TestFunctions: + + _TEST_FUNCTIONS_PARAMS = [ + {'function_name': 'function-name'}, + {'function_name': 'projects/test-project/locations/test-location/functions/function-name'}, + {'function_name': 'function-name', 'extension_id': 'extension-id'}, + { + 'function_name': \ + 'projects/test-project/locations/test-location/functions/function-name', + 'extension_id': 'extension-id' + } + ] + + @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) + def test_task_queue(self, task_queue_params, app): + assert app.name == 'integration-functions' + queue = functions.task_queue(**task_queue_params, app=app) + assert queue is not None + assert callable(queue.enqueue) + assert callable(queue.delete) + + def test_task_enqueue(self, app): + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert task_id is not None + + @pytest.mark.skipif( + os.environ.get('CLOUD_TASKS_EMULATOR_HOST') is not None, + reason="Skipping test_task_delete against emulator due to bug in firebase-tools" + ) + def test_task_delete(self, app): + # Skip this test against the emulator since tasks can't be delayed there to verify deletion + # See: https://github.com/firebase/firebase-tools/issues/8254 + task_options = functions.TaskOptions(schedule_delay_seconds=60) + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA, task_options) + assert task_id is not None + queue.delete(task_id) + # We don't have a way to check the contents of the queue so we check that the deleted + # task is not found using the delete method again. + with pytest.raises(firebase_admin.exceptions.NotFoundError): + queue.delete(task_id) diff --git a/integration/test_messaging.py b/integration/test_messaging.py index 001b96a0a..e72086741 100644 --- a/integration/test_messaging.py +++ b/integration/test_messaging.py @@ -55,7 +55,8 @@ def test_send(): light_off_duration_millis=200, light_on_duration_millis=300 ), - notification_count=1 + notification_count=1, + proxy='if_priority_lowered', ) ), apns=messaging.APNSConfig(payload=messaging.APNSPayload( @@ -75,7 +76,7 @@ def test_send_invalid_token(): token=_REGISTRATION_TOKEN, notification=messaging.Notification('test-title', 'test-body') ) - with pytest.raises(messaging.SenderIdMismatchError): + with pytest.raises(messaging.UnregisteredError): messaging.send(msg, dry_run=True) def test_send_malformed_token(): @@ -86,7 +87,7 @@ def test_send_malformed_token(): with pytest.raises(exceptions.InvalidArgumentError): messaging.send(msg, dry_run=True) -def test_send_all(): +def test_send_each(): messages = [ messaging.Message( topic='foo-bar', notification=messaging.Notification('Title', 'Body')), @@ -96,7 +97,7 @@ def test_send_all(): token='not-a-token', notification=messaging.Notification('Title', 'Body')), ] - batch_response = messaging.send_all(messages, dry_run=True) + batch_response = messaging.send_each(messages, dry_run=True) assert batch_response.success_count == 2 assert batch_response.failure_count == 1 @@ -117,13 +118,13 @@ def test_send_all(): assert isinstance(response.exception, exceptions.InvalidArgumentError) assert response.message_id is None -def test_send_all_500(): +def test_send_each_500(): messages = [] for msg_number in range(500): - topic = 'foo-bar-{0}'.format(msg_number % 10) + topic = f'foo-bar-{msg_number % 10}' messages.append(messaging.Message(topic=topic)) - batch_response = messaging.send_all(messages, dry_run=True) + batch_response = messaging.send_each(messages, dry_run=True) assert batch_response.success_count == 500 assert batch_response.failure_count == 0 @@ -133,12 +134,12 @@ def test_send_all_500(): assert response.exception is None assert re.match('^projects/.*/messages/.*$', response.message_id) -def test_send_multicast(): +def test_send_each_for_multicast(): multicast = messaging.MulticastMessage( notification=messaging.Notification('Title', 'Body'), tokens=['not-a-token', 'also-not-a-token']) - batch_response = messaging.send_multicast(multicast) + batch_response = messaging.send_each_for_multicast(multicast) assert batch_response.success_count == 0 assert batch_response.failure_count == 2 @@ -155,3 +156,68 @@ def test_subscribe(): def test_unsubscribe(): resp = messaging.unsubscribe_from_topic(_REGISTRATION_TOKEN, 'mock-topic') assert resp.success_count + resp.failure_count == 1 + +@pytest.mark.asyncio(loop_scope="session") +async def test_send_each_async(): + messages = [ + messaging.Message( + topic='foo-bar', notification=messaging.Notification('Title', 'Body')), + messaging.Message( + topic='foo-bar', notification=messaging.Notification('Title', 'Body')), + messaging.Message( + token='not-a-token', notification=messaging.Notification('Title', 'Body')), + ] + + batch_response = await messaging.send_each_async(messages, dry_run=True) + + assert batch_response.success_count == 2 + assert batch_response.failure_count == 1 + assert len(batch_response.responses) == 3 + + response = batch_response.responses[0] + assert response.success is True + assert response.exception is None + assert re.match('^projects/.*/messages/.*$', response.message_id) + + response = batch_response.responses[1] + assert response.success is True + assert response.exception is None + assert re.match('^projects/.*/messages/.*$', response.message_id) + + response = batch_response.responses[2] + assert response.success is False + assert isinstance(response.exception, exceptions.InvalidArgumentError) + assert response.message_id is None + +@pytest.mark.asyncio(loop_scope="session") +async def test_send_each_async_500(): + messages = [] + for msg_number in range(500): + topic = f'foo-bar-{msg_number % 10}' + messages.append(messaging.Message(topic=topic)) + + batch_response = await messaging.send_each_async(messages, dry_run=True) + + assert batch_response.success_count == 500 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 500 + for response in batch_response.responses: + assert response.success is True + assert response.exception is None + assert re.match('^projects/.*/messages/.*$', response.message_id) + +@pytest.mark.asyncio(loop_scope="session") +async def test_send_each_for_multicast_async(): + multicast = messaging.MulticastMessage( + notification=messaging.Notification('Title', 'Body'), + tokens=['not-a-token', 'also-not-a-token']) + + batch_response = await messaging.send_each_for_multicast_async(multicast) + + assert batch_response.success_count == 0 + assert batch_response.failure_count == 2 + assert len(batch_response.responses) == 2 + for response in batch_response.responses: + assert response.success is False + assert response.exception is not None + assert response.message_id is None diff --git a/integration/test_ml.py b/integration/test_ml.py index 1d32ebed1..ea5b10be9 100644 --- a/integration/test_ml.py +++ b/integration/test_ml.py @@ -27,18 +27,17 @@ from tests import testutils -# pylint: disable=import-error,no-name-in-module +# pylint: disable=import-error, no-member try: import tensorflow as tf _TF_ENABLED = True except ImportError: _TF_ENABLED = False - def _random_identifier(prefix): #pylint: disable=unused-variable suffix = ''.join([random.choice(string.ascii_letters + string.digits) for n in range(8)]) - return '{0}_{1}'.format(prefix, suffix) + return f'{prefix}_{suffix}' NAME_ONLY_ARGS = { @@ -62,7 +61,6 @@ def _random_identifier(prefix): 'file_name': 'invalid_model.tflite' } - @pytest.fixture def firebase_model(request): args = request.param @@ -101,6 +99,7 @@ def _clean_up_model(model): try: # Try to delete the model. # Some tests delete the model as part of the test. + model.wait_for_unlocked() ml.delete_model(model.model_id) except exceptions.NotFoundError: pass @@ -132,35 +131,37 @@ def check_model(model, args): assert model.locked is False assert model.etag is not None +# Model Format Checks -def check_model_format(model, has_model_format=False, validation_error=None): - if has_model_format: - assert model.validation_error == validation_error - assert model.published is False - assert model.model_format.model_source.gcs_tflite_uri.startswith('gs://') - if validation_error: - assert model.model_format.size_bytes is None - assert model.model_hash is None - else: - assert model.model_format.size_bytes is not None - assert model.model_hash is not None - else: - assert model.model_format is None - assert model.validation_error == 'No model file has been uploaded.' - assert model.published is False +def check_no_model_format(model): + assert model.model_format is None + assert model.validation_error == 'No model file has been uploaded.' + assert model.published is False + assert model.model_hash is None + + +def check_tflite_gcs_format(model, validation_error=None): + assert model.validation_error == validation_error + assert model.published is False + assert model.model_format.model_source.gcs_tflite_uri.startswith('gs://') + if validation_error: + assert model.model_format.size_bytes is None assert model.model_hash is None + else: + assert model.model_format.size_bytes is not None + assert model.model_hash is not None @pytest.mark.parametrize('firebase_model', [NAME_AND_TAGS_ARGS], indirect=True) def test_create_simple_model(firebase_model): check_model(firebase_model, NAME_AND_TAGS_ARGS) - check_model_format(firebase_model) + check_no_model_format(firebase_model) @pytest.mark.parametrize('firebase_model', [FULL_MODEL_ARGS], indirect=True) def test_create_full_model(firebase_model): check_model(firebase_model, FULL_MODEL_ARGS) - check_model_format(firebase_model, True) + check_tflite_gcs_format(firebase_model) @pytest.mark.parametrize('firebase_model', [FULL_MODEL_ARGS], indirect=True) @@ -169,20 +170,20 @@ def test_create_already_existing_fails(firebase_model): ml.create_model(model=firebase_model) check_operation_error( excinfo, - 'Model \'{0}\' already exists'.format(firebase_model.display_name)) + f'Model \'{firebase_model.display_name}\' already exists') @pytest.mark.parametrize('firebase_model', [INVALID_FULL_MODEL_ARGS], indirect=True) def test_create_invalid_model(firebase_model): check_model(firebase_model, INVALID_FULL_MODEL_ARGS) - check_model_format(firebase_model, True, 'Invalid flatbuffer format') + check_tflite_gcs_format(firebase_model, 'Invalid flatbuffer format') @pytest.mark.parametrize('firebase_model', [NAME_AND_TAGS_ARGS], indirect=True) def test_get_model(firebase_model): get_model = ml.get_model(firebase_model.model_id) check_model(get_model, NAME_AND_TAGS_ARGS) - check_model_format(get_model) + check_no_model_format(get_model) @pytest.mark.parametrize('firebase_model', [NAME_ONLY_ARGS], indirect=True) @@ -201,12 +202,12 @@ def test_update_model(firebase_model): firebase_model.display_name = new_model_name updated_model = ml.update_model(firebase_model) check_model(updated_model, NAME_ONLY_ARGS_UPDATED) - check_model_format(updated_model) + check_no_model_format(updated_model) # Second call with same model does not cause error updated_model2 = ml.update_model(updated_model) check_model(updated_model2, NAME_ONLY_ARGS_UPDATED) - check_model_format(updated_model2) + check_no_model_format(updated_model2) @pytest.mark.parametrize('firebase_model', [NAME_ONLY_ARGS], indirect=True) @@ -218,7 +219,7 @@ def test_update_non_existing_model(firebase_model): ml.update_model(firebase_model) check_operation_error( excinfo, - 'Model \'{0}\' was not found'.format(firebase_model.as_dict().get('name'))) + f'Model \'{firebase_model.as_dict().get("name")}\' was not found') @pytest.mark.parametrize('firebase_model', [FULL_MODEL_ARGS], indirect=True) @@ -251,18 +252,17 @@ def test_publish_unpublish_non_existing_model(firebase_model): ml.publish_model(firebase_model.model_id) check_operation_error( excinfo, - 'Model \'{0}\' was not found'.format(firebase_model.as_dict().get('name'))) + f'Model \'{firebase_model.as_dict().get("name")}\' was not found') with pytest.raises(exceptions.NotFoundError) as excinfo: ml.unpublish_model(firebase_model.model_id) check_operation_error( excinfo, - 'Model \'{0}\' was not found'.format(firebase_model.as_dict().get('name'))) + f'Model \'{firebase_model.as_dict().get("name")}\' was not found') def test_list_models(model_list): - filter_str = 'displayName={0} OR tags:{1}'.format( - model_list[0].display_name, model_list[1].tags[0]) + filter_str = f'displayName={model_list[0].display_name} OR tags:{model_list[1].tags[0]}' all_models = ml.list_models(list_filter=filter_str) all_model_ids = [mdl.model_id for mdl in all_models.iterate_all()] @@ -290,7 +290,7 @@ def test_delete_model(firebase_model): # Test tensor flow conversion functions if tensor flow is enabled. #'pip install tensorflow' in the environment if you want _TF_ENABLED = True -#'pip install tensorflow==2.0.0b' for version 2 etc. +#'pip install tensorflow==2.2.0' for version 2.2.0 etc. def _clean_up_directory(save_dir): @@ -301,12 +301,16 @@ def _clean_up_directory(save_dir): @pytest.fixture def keras_model(): assert _TF_ENABLED - x_array = [-1, 0, 1, 2, 3, 4] - y_array = [-3, -1, 1, 3, 5, 7] - model = tf.keras.models.Sequential( - [tf.keras.layers.Dense(units=1, input_shape=[1])]) + x_list = [-1, 0, 1, 2, 3, 4] + y_list = [-3, -1, 1, 3, 5, 7] + x_tensor = tf.convert_to_tensor(x_list, dtype=tf.float32) + y_tensor = tf.convert_to_tensor(y_list, dtype=tf.float32) + model = tf.keras.models.Sequential([ + tf.keras.Input(shape=(1,)), + tf.keras.layers.Dense(units=1) + ]) model.compile(optimizer='sgd', loss='mean_squared_error') - model.fit(x_array, y_array, epochs=3) + model.fit(x_tensor, y_tensor, epochs=3) return model @@ -334,6 +338,7 @@ def saved_model_dir(keras_model): _clean_up_directory(parent) + @pytest.mark.skipif(not _TF_ENABLED, reason='Tensor flow is required for this test.') def test_from_keras_model(keras_model): source = ml.TFLiteGCSModelSource.from_keras_model(keras_model, 'model2.tflite') @@ -348,7 +353,7 @@ def test_from_keras_model(keras_model): try: check_model(created_model, {'display_name': model.display_name}) - check_model_format(created_model, True) + check_tflite_gcs_format(created_model) finally: _clean_up_model(created_model) diff --git a/integration/test_project_management.py b/integration/test_project_management.py index ca648f12d..ba2c5ec16 100644 --- a/integration/test_project_management.py +++ b/integration/test_project_management.py @@ -28,8 +28,6 @@ TEST_APP_PACKAGE_NAME = 'com.firebase.adminsdk_python_integration_test' TEST_APP_DISPLAY_NAME_PREFIX = 'Created By Firebase AdminSDK Python Integration Testing' -SHA_1_HASH_1 = '123456789a123456789a123456789a123456789a' -SHA_1_HASH_2 = 'aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb' SHA_256_HASH_1 = '123456789a123456789a123456789a123456789a123456789a123456789a1234' SHA_256_HASH_2 = 'cafef00dba5eba11b01dfaceacc01adeda7aba5eca55e77e0b57ac1e5ca1ab1e' SHA_1 = project_management.SHACertificate.SHA_1 @@ -76,14 +74,13 @@ def test_create_android_app_already_exists(android_app): def test_android_set_display_name_and_get_metadata(android_app, project_id): app_id = android_app.app_id android_app = project_management.android_app(app_id) - new_display_name = '{0} helloworld {1}'.format( - TEST_APP_DISPLAY_NAME_PREFIX, random.randint(0, 10000)) + new_display_name = f'{TEST_APP_DISPLAY_NAME_PREFIX} helloworld {random.randint(0, 10000)}' android_app.set_display_name(new_display_name) metadata = project_management.android_app(app_id).get_metadata() android_app.set_display_name(TEST_APP_DISPLAY_NAME_PREFIX) # Revert the display name. - assert metadata._name == 'projects/{0}/androidApps/{1}'.format(project_id, app_id) + assert metadata._name == f'projects/{project_id}/androidApps/{app_id}' assert metadata.app_id == app_id assert metadata.project_id == project_id assert metadata.display_name == new_display_name @@ -119,41 +116,23 @@ def test_android_sha_certificates(android_app): for cert in android_app.get_sha_certificates(): android_app.delete_sha_certificate(cert) - # Add four different certs and assert that they have all been added successfully. - android_app.add_sha_certificate(project_management.SHACertificate(SHA_1_HASH_1)) - android_app.add_sha_certificate(project_management.SHACertificate(SHA_1_HASH_2)) + # Add two different certs and assert that they have all been added successfully. android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_1)) android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_2)) cert_list = android_app.get_sha_certificates() - sha_1_hashes = set(cert.sha_hash for cert in cert_list if cert.cert_type == SHA_1) sha_256_hashes = set(cert.sha_hash for cert in cert_list if cert.cert_type == SHA_256) - assert sha_1_hashes == set([SHA_1_HASH_1, SHA_1_HASH_2]) assert sha_256_hashes == set([SHA_256_HASH_1, SHA_256_HASH_2]) for cert in cert_list: assert cert.name - # Adding the same cert twice should cause an already-exists error. - with pytest.raises(exceptions.AlreadyExistsError) as excinfo: - android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_2)) - assert 'Requested entity already exists' in str(excinfo.value) - assert excinfo.value.cause is not None - assert excinfo.value.http_response is not None - # Delete all certs and assert that they have all been deleted successfully. for cert in cert_list: android_app.delete_sha_certificate(cert) assert android_app.get_sha_certificates() == [] - # Deleting a nonexistent cert should cause a not-found error. - with pytest.raises(exceptions.NotFoundError) as excinfo: - android_app.delete_sha_certificate(cert_list[0]) - assert 'Requested entity was not found' in str(excinfo.value) - assert excinfo.value.cause is not None - assert excinfo.value.http_response is not None - def test_create_ios_app_already_exists(ios_app): del ios_app @@ -169,15 +148,13 @@ def test_create_ios_app_already_exists(ios_app): def test_ios_set_display_name_and_get_metadata(ios_app, project_id): app_id = ios_app.app_id ios_app = project_management.ios_app(app_id) - new_display_name = '{0} helloworld {1}'.format( - TEST_APP_DISPLAY_NAME_PREFIX, random.randint(0, 10000)) + new_display_name = f'{TEST_APP_DISPLAY_NAME_PREFIX} helloworld {random.randint(0, 10000)}' ios_app.set_display_name(new_display_name) metadata = project_management.ios_app(app_id).get_metadata() ios_app.set_display_name(TEST_APP_DISPLAY_NAME_PREFIX) # Revert the display name. - assert metadata._name == 'projects/{0}/iosApps/{1}'.format(project_id, app_id) - assert metadata.app_id == app_id + assert metadata._name == f'projects/{project_id}/iosApps/{app_id}' assert metadata.project_id == project_id assert metadata.display_name == new_display_name assert metadata.bundle_id == TEST_APP_BUNDLE_ID @@ -195,12 +172,8 @@ def test_list_ios_apps(ios_app): def test_get_ios_app_config(ios_app, project_id): config = ios_app.get_config() - # In Python 2.7, the plistlib module works with strings, while in Python 3, it is significantly - # redesigned and works with bytes objects instead. - try: - plist = plistlib.loads(config.encode('utf-8')) - except AttributeError: # Python 2.7 plistlib does not have the loads attribute. - plist = plistlib.readPlistFromString(config) # pylint: disable=no-member + plist = plistlib.loads(config.encode('utf-8')) + assert plist['BUNDLE_ID'] == TEST_APP_BUNDLE_ID assert plist['PROJECT_ID'] == project_id assert plist['GOOGLE_APP_ID'] == ios_app.app_id diff --git a/integration/test_storage.py b/integration/test_storage.py index 729190950..32e4d86a3 100644 --- a/integration/test_storage.py +++ b/integration/test_storage.py @@ -20,10 +20,10 @@ def test_default_bucket(project_id): bucket = storage.bucket() - _verify_bucket(bucket, '{0}.appspot.com'.format(project_id)) + _verify_bucket(bucket, f'{project_id}.appspot.com') def test_custom_bucket(project_id): - bucket_name = '{0}.appspot.com'.format(project_id) + bucket_name = f'{project_id}.appspot.com' bucket = storage.bucket(bucket_name) _verify_bucket(bucket, bucket_name) @@ -33,12 +33,12 @@ def test_non_existing_bucket(): def _verify_bucket(bucket, expected_name): assert bucket.name == expected_name - file_name = 'data_{0}.txt'.format(int(time.time())) + file_name = f'data_{int(time.time())}.txt' blob = bucket.blob(file_name) blob.upload_from_string('Hello World') blob = bucket.get_blob(file_name) - assert blob.download_as_string().decode() == 'Hello World' + assert blob.download_as_bytes().decode() == 'Hello World' bucket.delete_blob(file_name) assert not bucket.get_blob(file_name) diff --git a/integration/test_tenant_mgt.py b/integration/test_tenant_mgt.py index c9eefd96e..f0bad58b2 100644 --- a/integration/test_tenant_mgt.py +++ b/integration/test_tenant_mgt.py @@ -25,6 +25,7 @@ from firebase_admin import auth from firebase_admin import tenant_mgt +from firebase_admin._http_client import DEFAULT_TIMEOUT_SECONDS as timeout from integration import test_auth @@ -359,7 +360,7 @@ def test_delete_saml_provider_config(sample_tenant): def _create_oidc_provider_config(client): - provider_id = 'oidc.{0}'.format(_random_string()) + provider_id = f'oidc.{_random_string()}' return client.create_oidc_provider_config( provider_id=provider_id, client_id='OIDC_CLIENT_ID', @@ -369,7 +370,7 @@ def _create_oidc_provider_config(client): def _create_saml_provider_config(client): - provider_id = 'saml.{0}'.format(_random_string()) + provider_id = f'saml.{_random_string()}' return client.create_saml_provider_config( provider_id=provider_id, idp_entity_id='IDP_ENTITY_ID', @@ -387,7 +388,7 @@ def _random_uid(): def _random_email(): random_id = str(uuid.uuid4()).lower().replace('-', '') - return 'test{0}@example.{1}.com'.format(random_id[:12], random_id[12:]) + return f'test{random_id[:12]}@example.{random_id[12:]}.com' def _random_phone(): @@ -412,6 +413,6 @@ def _sign_in(custom_token, tenant_id, api_key): 'tenantId': tenant_id, } params = {'key' : api_key} - resp = requests.request('post', VERIFY_TOKEN_URL, params=params, json=body) + resp = requests.request('post', VERIFY_TOKEN_URL, params=params, json=body, timeout=timeout) resp.raise_for_status() return resp.json().get('idToken') diff --git a/requirements.txt b/requirements.txt index dbeaee3b6..3b96eea00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,15 @@ -astroid == 2.3.3 -pylint == 2.3.1 -pytest >= 3.6.0 +astroid == 3.3.11 +pylint == 3.3.9 +pytest >= 8.2.2 pytest-cov >= 2.4.0 pytest-localserver >= 0.4.1 +pytest-asyncio >= 0.26.0 +pytest-mock >= 3.6.1 +respx == 0.22.0 -cachecontrol >= 0.12.6 -google-api-core[grpc] >= 1.14.0, < 2.0.0dev; platform.python_implementation != 'PyPy' -google-api-python-client >= 1.7.8 -google-cloud-firestore >= 1.4.0; platform.python_implementation != 'PyPy' -google-cloud-storage >= 1.18.0 +cachecontrol >= 0.14.3 +google-api-core[grpc] >= 2.25.1, < 3.0.0dev; platform.python_implementation != 'PyPy' +google-cloud-firestore >= 2.21.0; platform.python_implementation != 'PyPy' +google-cloud-storage >= 3.1.1 +pyjwt[crypto] >= 2.10.1 +httpx[http2] == 0.28.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 25c649748..4c6cf8d8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,3 @@ [tool:pytest] testpaths = tests +asyncio_default_test_loop_scope = class diff --git a/setup.py b/setup.py index 0ebcc3455..21e29332e 100644 --- a/setup.py +++ b/setup.py @@ -22,8 +22,8 @@ (major, minor) = (sys.version_info.major, sys.version_info.minor) -if major != 3 or minor < 5: - print('firebase_admin requires python >= 3.5', file=sys.stderr) +if major != 3 or minor < 9: + print('firebase_admin requires python >= 3.9', file=sys.stderr) sys.exit(1) # Read in the package metadata per recommendations from: @@ -37,11 +37,12 @@ long_description = ('The Firebase Admin Python SDK enables server-side (backend) Python developers ' 'to integrate Firebase into their services and applications.') install_requires = [ - 'cachecontrol>=0.12.6', - 'google-api-core[grpc] >= 1.14.0, < 2.0.0dev; platform.python_implementation != "PyPy"', - 'google-api-python-client >= 1.7.8', - 'google-cloud-firestore>=1.4.0; platform.python_implementation != "PyPy"', - 'google-cloud-storage>=1.18.0', + 'cachecontrol>=0.14.3', + 'google-api-core[grpc] >= 2.25.1, < 3.0.0dev; platform.python_implementation != "PyPy"', + 'google-cloud-firestore>=2.21.0; platform.python_implementation != "PyPy"', + 'google-cloud-storage>=3.1.1', + 'pyjwt[crypto] >= 2.10.1', + 'httpx[http2] == 0.28.1', ] setup( @@ -50,20 +51,26 @@ description='Firebase Admin Python SDK', long_description=long_description, url=about['__url__'], + project_urls={ + 'Release Notes': 'https://firebase.google.com/support/release-notes/admin/python', + 'Source': 'https://github.com/firebase/firebase-admin-python', + }, author=about['__author__'], license=about['__license__'], keywords='firebase cloud development', install_requires=install_requires, packages=['firebase_admin'], - python_requires='>=3.5', + python_requires='>=3.9', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Topic :: Software Development :: Build Tools', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'License :: OSI Approved :: Apache Software License', ], ) diff --git a/snippets/auth/get_service_account_tokens.py b/snippets/auth/get_service_account_tokens.py index 9f60590fe..7ad67a093 100644 --- a/snippets/auth/get_service_account_tokens.py +++ b/snippets/auth/get_service_account_tokens.py @@ -26,4 +26,4 @@ # After expiration_time, you must generate a new access token # [END get_service_account_tokens] -print('The access token {} expires at {}'.format(access_token, expiration_time)) +print(f'The access token {access_token} expires at {expiration_time}') diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 9de9cfa03..656137dba 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -150,6 +150,9 @@ def verify_token_uid_check_revoke(id_token): except auth.RevokedIdTokenError: # Token revoked, inform the user to reauthenticate or signOut(). pass + except auth.UserDisabledError: + # Token belongs to a disabled user record. + pass except auth.InvalidIdTokenError: # Token is invalid pass @@ -166,7 +169,7 @@ def revoke_refresh_token_uid(): user = auth.get_user(uid) # Convert to seconds as the auth_time in the token claims is in seconds. revocation_second = user.tokens_valid_after_timestamp / 1000 - print('Tokens revoked at: {0}'.format(revocation_second)) + print(f'Tokens revoked at: {revocation_second}') # [END revoke_tokens] # [START save_revocation_in_db] metadata_ref = firebase_admin.db.reference("metadata/" + uid) @@ -180,7 +183,7 @@ def get_user(uid): from firebase_admin import auth user = auth.get_user(uid) - print('Successfully fetched user data: {0}'.format(user.uid)) + print(f'Successfully fetched user data: {user.uid}') # [END get_user] def get_user_by_email(): @@ -189,7 +192,7 @@ def get_user_by_email(): from firebase_admin import auth user = auth.get_user_by_email(email) - print('Successfully fetched user data: {0}'.format(user.uid)) + print(f'Successfully fetched user data: {user.uid}') # [END get_user_by_email] def bulk_get_users(): @@ -218,7 +221,7 @@ def get_user_by_phone_number(): from firebase_admin import auth user = auth.get_user_by_phone_number(phone) - print('Successfully fetched user data: {0}'.format(user.uid)) + print(f'Successfully fetched user data: {user.uid}') # [END get_user_by_phone] def create_user(): @@ -231,7 +234,7 @@ def create_user(): display_name='John Doe', photo_url='http://www.example.com/12345678/photo.png', disabled=False) - print('Sucessfully created new user: {0}'.format(user.uid)) + print(f'Sucessfully created new user: {user.uid}') # [END create_user] return user.uid @@ -239,7 +242,7 @@ def create_user_with_id(): # [START create_user_with_id] user = auth.create_user( uid='some-uid', email='user@example.com', phone_number='+15555550100') - print('Sucessfully created new user: {0}'.format(user.uid)) + print(f'Sucessfully created new user: {user.uid}') # [END create_user_with_id] def update_user(uid): @@ -253,7 +256,7 @@ def update_user(uid): display_name='John Doe', photo_url='http://www.example.com/12345678/photo.png', disabled=True) - print('Sucessfully updated user: {0}'.format(user.uid)) + print(f'Sucessfully updated user: {user.uid}') # [END update_user] def delete_user(uid): @@ -268,10 +271,10 @@ def bulk_delete_users(): result = auth.delete_users(["uid1", "uid2", "uid3"]) - print('Successfully deleted {0} users'.format(result.success_count)) - print('Failed to delete {0} users'.format(result.failure_count)) + print(f'Successfully deleted {result.success_count} users') + print(f'Failed to delete {result.failure_count} users') for err in result.errors: - print('error #{0}, reason: {1}'.format(result.index, result.reason)) + print(f'error #{result.index}, reason: {result.reason}') # [END bulk_delete_users] def set_custom_user_claims(uid): @@ -472,10 +475,11 @@ def import_users(): hash_alg = auth.UserImportHash.hmac_sha256(key=b'secret_key') try: result = auth.import_users(users, hash_alg=hash_alg) - print('Successfully imported {0} users. Failed to import {1} users.'.format( - result.success_count, result.failure_count)) + print( + f'Successfully imported {result.success_count} users. Failed to import ' + f'{result.failure_count} users.') for err in result.errors: - print('Failed to import {0} due to {1}'.format(users[err.index].uid, err.reason)) + print(f'Failed to import {users[err.index].uid} due to {err.reason}') except exceptions.FirebaseError: # Some unrecoverable error occurred that prevented the operation from running. pass @@ -568,8 +572,8 @@ def import_with_scrypt(): auth.ImportUserRecord( uid='some-uid', email='user@example.com', - password_hash=b'password_hash', - password_salt=b'salt' + password_hash=base64.urlsafe_b64decode('password_hash'), + password_salt=base64.urlsafe_b64decode('salt') ), ] @@ -766,7 +770,7 @@ def get_tenant(tenant_id): # [START get_tenant] tenant = tenant_mgt.get_tenant(tenant_id) - print('Retreieved tenant:', tenant.tenant_id) + print('Retrieved tenant:', tenant.tenant_id) # [END get_tenant] def create_tenant(): @@ -1009,7 +1013,7 @@ def revoke_refresh_tokens_tenant(tenant_client, uid): user = tenant_client.get_user(uid) # Convert to seconds as the auth_time in the token claims is in seconds. revocation_second = user.tokens_valid_after_timestamp / 1000 - print('Tokens revoked at: {0}'.format(revocation_second)) + print(f'Tokens revoked at: {revocation_second}') # [END revoke_tokens_tenant] def verify_id_token_and_check_revoked_tenant(tenant_client, id_token): @@ -1027,6 +1031,9 @@ def verify_id_token_and_check_revoked_tenant(tenant_client, id_token): except auth.RevokedIdTokenError: # Token revoked, inform the user to reauthenticate or signOut(). pass + except auth.UserDisabledError: + # Token belongs to a disabled user record. + pass except auth.InvalidIdTokenError: # Token is invalid pass diff --git a/snippets/database/index.py b/snippets/database/index.py index adfa13476..99bb4981e 100644 --- a/snippets/database/index.py +++ b/snippets/database/index.py @@ -235,7 +235,7 @@ def order_by_child(): ref = db.reference('dinosaurs') snapshot = ref.order_by_child('height').get() for key, val in snapshot.items(): - print('{0} was {1} meters tall'.format(key, val)) + print(f'{key} was {val} meters tall') # [END order_by_child] def order_by_nested_child(): @@ -243,7 +243,7 @@ def order_by_nested_child(): ref = db.reference('dinosaurs') snapshot = ref.order_by_child('dimensions/height').get() for key, val in snapshot.items(): - print('{0} was {1} meters tall'.format(key, val)) + print(f'{key} was {val} meters tall') # [END order_by_nested_child] def order_by_key(): @@ -258,7 +258,7 @@ def order_by_value(): ref = db.reference('scores') snapshot = ref.order_by_value().get() for key, val in snapshot.items(): - print('The {0} dinosaur\'s score is {1}'.format(key, val)) + print(f'The {key} dinosaur\'s score is {val}') # [END order_by_value] def limit_query(): @@ -280,7 +280,7 @@ def limit_query(): scores_ref = db.reference('scores') snapshot = scores_ref.order_by_value().limit_to_last(3).get() for key, val in snapshot.items(): - print('The {0} dinosaur\'s score is {1}'.format(key, val)) + print(f'The {key} dinosaur\'s score is {val}') # [END limit_query_3] def range_query(): @@ -300,7 +300,7 @@ def range_query(): # [START range_query_3] ref = db.reference('dinosaurs') - snapshot = ref.order_by_key().start_at('b').end_at(u'b\uf8ff').get() + snapshot = ref.order_by_key().start_at('b').end_at('b\uf8ff').get() for key in snapshot: print(key) # [END range_query_3] @@ -322,7 +322,7 @@ def complex_query(): # Data is ordered by increasing height, so we want the first entry. # Second entry is stegosarus. for key in snapshot: - print('The dinosaur just shorter than the stegosaurus is {0}'.format(key)) + print(f'The dinosaur just shorter than the stegosaurus is {key}') return else: print('The stegosaurus is the shortest dino') diff --git a/snippets/firestore/__init__.py b/snippets/firestore/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/snippets/firestore/firestore.py b/snippets/firestore/firestore.py new file mode 100644 index 000000000..18040b742 --- /dev/null +++ b/snippets/firestore/firestore.py @@ -0,0 +1,84 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from firebase_admin import firestore + +# pylint: disable=invalid-name +def init_firestore_client(): + # [START init_firestore_client] + import firebase_admin + from firebase_admin import firestore + + # Application Default credentials are automatically created. + app = firebase_admin.initialize_app() + db = firestore.client() + # [END init_firestore_client] + +def init_firestore_client_application_default(): + # [START init_firestore_client_application_default] + import firebase_admin + from firebase_admin import credentials + from firebase_admin import firestore + + # Use the application default credentials. + cred = credentials.ApplicationDefault() + + firebase_admin.initialize_app(cred) + db = firestore.client() + # [END init_firestore_client_application_default] + +def init_firestore_client_service_account(): + # [START init_firestore_client_service_account] + import firebase_admin + from firebase_admin import credentials + from firebase_admin import firestore + + # Use a service account. + cred = credentials.Certificate('path/to/serviceAccount.json') + + app = firebase_admin.initialize_app(cred) + + db = firestore.client() + # [END init_firestore_client_service_account] + +def read_data(): + import firebase_admin + from firebase_admin import firestore + + app = firebase_admin.initialize_app() + db = firestore.client() + + # [START read_data] + doc_ref = db.collection('users').document('alovelace') + doc = doc_ref.get() + if doc.exists: + return f'data: {doc.to_dict()}' + return "Document does not exist." + # [END read_data] + +def add_data(): + import firebase_admin + from firebase_admin import firestore + + app = firebase_admin.initialize_app() + db = firestore.client() + + # [START add_data] + doc_ref = db.collection("users").document("alovelace") + doc_ref.set({ + "first": "Ada", + "last": "Lovelace", + "born": 1815 + }) + # [END add_data] diff --git a/snippets/firestore/firestore_async.py b/snippets/firestore/firestore_async.py new file mode 100644 index 000000000..cf815504e --- /dev/null +++ b/snippets/firestore/firestore_async.py @@ -0,0 +1,132 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio + +from firebase_admin import firestore_async + +# pylint: disable=invalid-name +def init_firestore_async_client(): + # [START init_firestore_async_client] + import firebase_admin + from firebase_admin import firestore_async + + # Application Default credentials are automatically created. + app = firebase_admin.initialize_app() + db = firestore_async.client() + # [END init_firestore_async_client] + +def init_firestore_async_client_application_default(): + # [START init_firestore_async_client_application_default] + import firebase_admin + from firebase_admin import credentials + from firebase_admin import firestore_async + + # Use the application default credentials. + cred = credentials.ApplicationDefault() + + firebase_admin.initialize_app(cred) + db = firestore_async.client() + # [END init_firestore_async_client_application_default] + +def init_firestore_async_client_service_account(): + # [START init_firestore_async_client_service_account] + import firebase_admin + from firebase_admin import credentials + from firebase_admin import firestore_async + + # Use a service account. + cred = credentials.Certificate('path/to/serviceAccount.json') + + app = firebase_admin.initialize_app(cred) + + db = firestore_async.client() + # [END init_firestore_async_client_service_account] + +def close_async_sessions(): + import firebase_admin + from firebase_admin import firestore_async + + # [START close_async_sessions] + app = firebase_admin.initialize_app() + db = firestore_async.client() + + # Perform firestore tasks... + + # Delete app to ensure that all the async sessions are closed gracefully. + firebase_admin.delete_app(app) + # [END close_async_sessions] + +async def read_data(): + import firebase_admin + from firebase_admin import firestore_async + + app = firebase_admin.initialize_app() + db = firestore_async.client() + + # [START read_data] + doc_ref = db.collection('users').document('alovelace') + doc = await doc_ref.get() + if doc.exists: + return f'data: {doc.to_dict()}' + # [END read_data] + +async def add_data(): + import firebase_admin + from firebase_admin import firestore_async + + app = firebase_admin.initialize_app() + db = firestore_async.client() + + # [START add_data] + doc_ref = db.collection("users").document("alovelace") + await doc_ref.set({ + "first": "Ada", + "last": "Lovelace", + "born": 1815 + }) + # [END add_data] + +def firestore_async_client_with_asyncio_eventloop(): + # [START firestore_async_client_with_asyncio_eventloop] + import asyncio + import firebase_admin + from firebase_admin import firestore_async + + app = firebase_admin.initialize_app() + db = firestore_async.client() + + # Create coroutine to add user data. + async def add_data(): + doc_ref = db.collection("users").document("alovelace") + print("Start adding user...") + await doc_ref.set({ + "first": "Ada", + "last": "Lovelace", + "born": 1815 + }) + print("Done adding user!") + + # Another corutine with secondary tasks we want to complete. + async def while_waiting(): + print("Start other tasks...") + await asyncio.sleep(2) + print("Finished with other tasks!") + + # Initialize an eventloop to execute tasks until completion. + loop = asyncio.get_event_loop() + tasks = [add_data(), while_waiting()] + loop.run_until_complete(asyncio.gather(*tasks)) + firebase_admin.delete_app(app) + # [END firestore_async_client_with_asyncio_eventloop] diff --git a/snippets/messaging/cloud_messaging.py b/snippets/messaging/cloud_messaging.py index bb63db065..6fb525231 100644 --- a/snippets/messaging/cloud_messaging.py +++ b/snippets/messaging/cloud_messaging.py @@ -222,9 +222,9 @@ def unsubscribe_from_topic(): # [END unsubscribe] -def send_all(): +def send_each(): registration_token = 'YOUR_REGISTRATION_TOKEN' - # [START send_all] + # [START send_each] # Create a list containing up to 500 messages. messages = [ messaging.Message( @@ -238,36 +238,14 @@ def send_all(): ), ] - response = messaging.send_all(messages) + response = messaging.send_each(messages) # See the BatchResponse reference documentation # for the contents of response. - print('{0} messages were sent successfully'.format(response.success_count)) - # [END send_all] + print(f'{response.success_count} messages were sent successfully') + # [END send_each] - -def send_multicast(): - # [START send_multicast] - # Create a list containing up to 500 registration tokens. - # These registration tokens come from the client FCM SDKs. - registration_tokens = [ - 'YOUR_REGISTRATION_TOKEN_1', - # ... - 'YOUR_REGISTRATION_TOKEN_N', - ] - - message = messaging.MulticastMessage( - data={'score': '850', 'time': '2:45'}, - tokens=registration_tokens, - ) - response = messaging.send_multicast(message) - # See the BatchResponse reference documentation - # for the contents of response. - print('{0} messages were sent successfully'.format(response.success_count)) - # [END send_multicast] - - -def send_multicast_and_handle_errors(): - # [START send_multicast_error] +def send_each_for_multicast_and_handle_errors(): + # [START send_each_for_multicast_error] # These registration tokens come from the client FCM SDKs. registration_tokens = [ 'YOUR_REGISTRATION_TOKEN_1', @@ -279,7 +257,7 @@ def send_multicast_and_handle_errors(): data={'score': '850', 'time': '2:45'}, tokens=registration_tokens, ) - response = messaging.send_multicast(message) + response = messaging.send_each_for_multicast(message) if response.failure_count > 0: responses = response.responses failed_tokens = [] @@ -287,5 +265,5 @@ def send_multicast_and_handle_errors(): if not resp.success: # The order of responses corresponds to the order of the registration tokens. failed_tokens.append(registration_tokens[idx]) - print('List of tokens that caused failures: {0}'.format(failed_tokens)) - # [END send_multicast_error] + print(f'List of tokens that caused failures: {failed_tokens}') + # [END send_each_for_multicast_error] diff --git a/tests/test_app.py b/tests/test_app.py index fe3a43a5c..0ff0854b4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -17,6 +17,7 @@ import os import pytest +from google.auth.exceptions import DefaultCredentialsError import firebase_admin from firebase_admin import credentials @@ -214,11 +215,11 @@ def revert_config_env(config_old): class TestFirebaseApp: """Test cases for App initialization and life cycle.""" - invalid_credentials = ['', 'foo', 0, 1, dict(), list(), tuple(), True, False] - invalid_options = ['', 0, 1, list(), tuple(), True, False] - invalid_names = [None, '', 0, 1, dict(), list(), tuple(), True, False] + invalid_credentials = ['', 'foo', 0, 1, {}, [], tuple(), True, False] + invalid_options = ['', 0, 1, [], tuple(), True, False] + invalid_names = [None, '', 0, 1, {}, [], tuple(), True, False] invalid_apps = [ - None, '', 0, 1, dict(), list(), tuple(), True, False, + None, '', 0, 1, {}, [], tuple(), True, False, firebase_admin.App('uninitialized', CREDENTIAL, {}) ] @@ -245,6 +246,16 @@ def test_non_default_app_init(self, app_credential): with pytest.raises(ValueError): firebase_admin.initialize_app(app_credential, name='myApp') + def test_app_init_with_google_auth_cred(self): + cred = testutils.MockGoogleCredential() + assert isinstance(cred, credentials.GoogleAuthCredentials) + app = firebase_admin.initialize_app(cred) + assert cred is app.credential.get_credential() + assert isinstance(app.credential, credentials.Base) + assert isinstance(app.credential, credentials._ExternalCredentials) + with pytest.raises(ValueError): + firebase_admin.initialize_app(app_credential) + @pytest.mark.parametrize('cred', invalid_credentials) def test_app_init_with_invalid_credential(self, cred): with pytest.raises(ValueError): @@ -297,11 +308,11 @@ def test_project_id_from_environment(self): variables = ['GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT'] for idx, var in enumerate(variables): old_project_id = os.environ.get(var) - new_project_id = 'env-project-{0}'.format(idx) + new_project_id = f'env-project-{idx}' os.environ[var] = new_project_id try: app = firebase_admin.initialize_app( - testutils.MockCredential(), name='myApp{0}'.format(var)) + testutils.MockCredential(), name=f'myApp{var}') assert app.project_id == new_project_id finally: if old_project_id: @@ -315,6 +326,27 @@ def evaluate(): assert app.project_id is None testutils.run_without_project_id(evaluate) + def test_no_project_id_from_environment(self, app_credential): + default_env = 'GOOGLE_APPLICATION_CREDENTIALS' + gcloud_env = 'CLOUDSDK_CONFIG' + def evaluate(): + app = firebase_admin.initialize_app(app_credential, name='myApp') + app._credential._g_credential = None + old_gcloud_var = os.environ.get(gcloud_env) + os.environ[gcloud_env] = '' + old_default_var = os.environ.get(default_env) + if old_default_var: + del os.environ[default_env] + with pytest.raises((AttributeError, DefaultCredentialsError)): + project_id = app._credential.project_id + project_id = app.project_id + if old_default_var: + os.environ[default_env] = old_default_var + if old_gcloud_var: + os.environ[gcloud_env] = old_gcloud_var + assert project_id is None + testutils.run_without_project_id(evaluate) + def test_non_string_project_id(self): options = {'projectId': {'key': 'not a string'}} with pytest.raises(ValueError): @@ -356,7 +388,7 @@ def test_app_services(self, init_app): with pytest.raises(ValueError): _utils.get_app_service(init_app, 'test.service', AppService) - @pytest.mark.parametrize('arg', [0, 1, True, False, 'str', list(), dict(), tuple()]) + @pytest.mark.parametrize('arg', [0, 1, True, False, 'str', [], {}, tuple()]) def test_app_services_invalid_arg(self, arg): with pytest.raises(ValueError): _utils.get_app_service(arg, 'test.service', AppService) diff --git a/tests/test_app_check.py b/tests/test_app_check.py new file mode 100644 index 000000000..e55ae39de --- /dev/null +++ b/tests/test_app_check.py @@ -0,0 +1,275 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the firebase_admin.app_check module.""" +import base64 +import pytest + +from jwt import PyJWK, InvalidAudienceError, InvalidIssuerError +from jwt import ExpiredSignatureError, InvalidSignatureError +import firebase_admin +from firebase_admin import app_check +from tests import testutils + +NON_STRING_ARGS = [[], tuple(), {}, True, False, 1, 0] + +APP_ID = "1234567890" +PROJECT_ID = "1334" +SCOPED_PROJECT_ID = f"projects/{PROJECT_ID}" +ISSUER = "https://firebaseappcheck.googleapis.com/" +JWT_PAYLOAD_SAMPLE = { + "headers": { + "alg": "RS256", + "typ": "JWT" + }, + "sub": APP_ID, + "name": "John Doe", + "iss": ISSUER, + "aud": [SCOPED_PROJECT_ID] +} + +secret_key = "secret" +signing_key = { + "kty": "oct", + # Using HS256 for simplicity, production key will use RS256 + "alg": "HS256", + "k": base64.urlsafe_b64encode(secret_key.encode()) +} + +class TestBatch: + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': PROJECT_ID}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + +class TestVerifyToken(TestBatch): + + def test_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + with pytest.raises(ValueError): + app_check.verify_token(token="app_check_token", app=app) + testutils.run_without_project_id(evaluate) + + @pytest.mark.parametrize('token', NON_STRING_ARGS) + def test_verify_token_with_non_string_raises_error(self, token): + with pytest.raises(ValueError) as excinfo: + app_check.verify_token(token) + expected = f'app check token "{token}" must be a string.' + assert str(excinfo.value) == expected + + def test_has_valid_token_headers(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + + headers = {"alg": "RS256", 'typ': "JWT"} + assert app_check_service._has_valid_token_headers(headers=headers) is None + + def test_has_valid_token_headers_with_incorrect_type_raises_error(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + headers = {"alg": "RS256", 'typ': "WRONG"} + with pytest.raises(ValueError) as excinfo: + app_check_service._has_valid_token_headers(headers=headers) + + expected = 'The provided App Check token has an incorrect type header' + assert str(excinfo.value) == expected + + def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + headers = {"alg": "HS256", 'typ': "JWT"} + with pytest.raises(ValueError) as excinfo: + app_check_service._has_valid_token_headers(headers=headers) + + expected = ('The provided App Check token has an incorrect alg header. ' + 'Expected RS256 but got HS256.') + assert str(excinfo.value) == expected + + def test_decode_and_verify(self, mocker): + jwt_decode_mock = mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + payload = app_check_service._decode_and_verify( + token=None, + signing_key="1234", + ) + + jwt_decode_mock.assert_called_once_with( + None, "1234", algorithms=["RS256"], audience=SCOPED_PROJECT_ID) + assert payload == JWT_PAYLOAD_SAMPLE.copy() + + def test_decode_and_verify_with_incorrect_token_and_key(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'Decoding App Check token failed. Error: Not enough segments') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_expired_token_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=ExpiredSignatureError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has expired.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_invalid_signature_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidSignatureError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has an invalid signature.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_invalid_aud_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidAudienceError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has an incorrect "aud" (audience) claim. ' + f'Expected payload to include {SCOPED_PROJECT_ID}.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_invalid_iss_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidIssuerError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has an incorrect "iss" (issuer) claim. ' + f'Expected claim to include {ISSUER}') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_none_sub_raises_error(self, mocker): + jwt_with_none_sub = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_none_sub['sub'] = None + mocker.patch("jwt.decode", return_value=jwt_with_none_sub) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token "sub" (subject) claim ' + f'"{None}" must be a non-empty string.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_non_string_sub_raises_error(self, mocker): + sub_number = 1234 + jwt_with_none_sub = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_none_sub['sub'] = sub_number + mocker.patch("jwt.decode", return_value=jwt_with_none_sub) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token "sub" (subject) claim ' + f'"{sub_number}" must be a string.') + assert str(excinfo.value) == expected + + def test_verify_token(self, mocker): + mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + payload = app_check.verify_token("encoded", app) + expected = JWT_PAYLOAD_SAMPLE.copy() + expected['app_id'] = APP_ID + assert payload == expected + + def test_verify_token_with_non_list_audience_raises_error(self, mocker): + jwt_with_non_list_audience = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_non_list_audience["aud"] = '1234' + mocker.patch("jwt.decode", return_value=jwt_with_non_list_audience) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Firebase App Check token has incorrect "aud" (audience) claim.' + assert str(excinfo.value) == expected + + def test_verify_token_with_empty_list_audience_raises_error(self, mocker): + jwt_with_empty_list_audience = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_empty_list_audience["aud"] = [] + mocker.patch("jwt.decode", return_value=jwt_with_empty_list_audience) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Firebase App Check token has incorrect "aud" (audience) claim.' + assert str(excinfo.value) == expected + + def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): + jwt_with_non_incorrect_issuer = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_non_incorrect_issuer["iss"] = "https://dwyfrequency.googleapis.com/" + mocker.patch("jwt.decode", return_value=jwt_with_non_incorrect_issuer) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Token does not contain the correct "iss" (issuer).' + assert str(excinfo.value) == expected diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 124aea3cc..106e1cae3 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -21,10 +21,18 @@ import firebase_admin from firebase_admin import auth from firebase_admin import exceptions -from firebase_admin import _auth_providers +from firebase_admin import _utils from tests import testutils -USER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' +ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v2' +EMULATOR_HOST_ENV_VAR = 'FIREBASE_AUTH_EMULATOR_HOST' +AUTH_EMULATOR_HOST = 'localhost:9099' +EMULATED_ID_TOOLKIT_URL = f'http://{AUTH_EMULATOR_HOST}/identitytoolkit.googleapis.com/v2' +URL_PROJECT_SUFFIX = '/projects/mock-project-id' +USER_MGT_URLS = { + 'ID_TOOLKIT': ID_TOOLKIT_URL, + 'PREFIX': ID_TOOLKIT_URL + URL_PROJECT_SUFFIX, +} OIDC_PROVIDER_CONFIG_RESPONSE = testutils.resource('oidc_provider_config.json') SAML_PROVIDER_CONFIG_RESPONSE = testutils.resource('saml_provider_config.json') LIST_OIDC_PROVIDER_CONFIGS_RESPONSE = testutils.resource('list_oidc_provider_configs.json') @@ -36,15 +44,21 @@ } }""" -INVALID_PROVIDER_IDS = [None, True, False, 1, 0, list(), tuple(), dict(), ''] +INVALID_PROVIDER_IDS = [None, True, False, 1, 0, [], tuple(), {}, ''] -@pytest.fixture(scope='module') -def user_mgt_app(): +@pytest.fixture(scope='module', params=[{'emulated': False}, {'emulated': True}]) +def user_mgt_app(request): + monkeypatch = testutils.new_monkeypatch() + if request.param['emulated']: + monkeypatch.setenv(EMULATOR_HOST_ENV_VAR, AUTH_EMULATOR_HOST) + monkeypatch.setitem(USER_MGT_URLS, 'ID_TOOLKIT', EMULATED_ID_TOOLKIT_URL) + monkeypatch.setitem(USER_MGT_URLS, 'PREFIX', EMULATED_ID_TOOLKIT_URL + URL_PROJECT_SUFFIX) app = firebase_admin.initialize_app(testutils.MockCredential(), name='providerConfig', options={'projectId': 'mock-project-id'}) yield app firebase_admin.delete_app(app) + monkeypatch.undo() def _instrument_provider_mgt(app, status, payload): @@ -52,10 +66,19 @@ def _instrument_provider_mgt(app, status, payload): provider_manager = client._provider_manager recorder = [] provider_manager.http_client.session.mount( - _auth_providers.ProviderConfigClient.PROVIDER_CONFIG_URL, + USER_MGT_URLS['ID_TOOLKIT'], testutils.MockAdapter(payload, status, recorder)) return recorder +def _assert_request(request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = [ + _utils.get_metrics_header(), + _utils.get_metrics_header() + ' mock-cred-metric-tag' + ] + assert request.headers['x-goog-api-client'] in expected_metrics_header class TestOIDCProviderConfig: @@ -65,13 +88,21 @@ class TestOIDCProviderConfig: 'issuer': 'https://oidc.com/issuer', 'display_name': 'oidcProviderName', 'enabled': True, + 'id_token_response_type': True, + 'code_response_type': True, + 'client_secret': 'CLIENT_SECRET', } OIDC_CONFIG_REQUEST = { 'displayName': 'oidcProviderName', 'enabled': True, 'clientId': 'CLIENT_ID', + 'clientSecret': 'CLIENT_SECRET', 'issuer': 'https://oidc.com/issuer', + 'responseType': { + 'code': True, + 'idToken': True, + }, } @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['saml.provider']) @@ -88,9 +119,8 @@ def test_get(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/oauthIdpConfigs/oidc.provider') + _assert_request( + recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs/oidc.provider') @pytest.mark.parametrize('invalid_opts', [ {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'saml.provider'}, @@ -98,6 +128,11 @@ def test_get(self, user_mgt_app): {'issuer': None}, {'issuer': ''}, {'issuer': 'not a url'}, {'display_name': True}, {'enabled': 'true'}, + {'id_token_response_type': 'true'}, {'code_response_type': 'true'}, + {'code_response_type': True, 'client_secret': ''}, + {'code_response_type': True, 'client_secret': True}, + {'code_response_type': True, 'client_secret': None}, + {'code_response_type': False, 'id_token_response_type': False}, ]) def test_create_invalid_args(self, user_mgt_app, invalid_opts): options = dict(self.VALID_CREATE_OPTIONS) @@ -113,11 +148,9 @@ def test_create(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider') + got = json.loads(recorder[0].body.decode()) assert got == self.OIDC_CONFIG_REQUEST def test_create_minimal(self, user_mgt_app): @@ -125,19 +158,22 @@ def test_create_minimal(self, user_mgt_app): options = dict(self.VALID_CREATE_OPTIONS) del options['display_name'] del options['enabled'] + del options['client_secret'] + del options['id_token_response_type'] + del options['code_response_type'] want = dict(self.OIDC_CONFIG_REQUEST) del want['displayName'] del want['enabled'] + del want['clientSecret'] + del want['responseType'] provider_config = auth.create_oidc_provider_config(**options, app=user_mgt_app) self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider') + got = json.loads(recorder[0].body.decode()) assert got == want def test_create_empty_values(self, user_mgt_app): @@ -145,19 +181,23 @@ def test_create_empty_values(self, user_mgt_app): options = dict(self.VALID_CREATE_OPTIONS) options['display_name'] = '' options['enabled'] = False + options['code_response_type'] = False want = dict(self.OIDC_CONFIG_REQUEST) want['displayName'] = '' want['enabled'] = False + want['responseType'] = { + 'code': False, + 'idToken': True, + } + del want['clientSecret'] provider_config = auth.create_oidc_provider_config(**options, app=user_mgt_app) self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?oauthIdpConfigId=oidc.provider') + got = json.loads(recorder[0].body.decode()) assert got == want @pytest.mark.parametrize('invalid_opts', [ @@ -167,6 +207,11 @@ def test_create_empty_values(self, user_mgt_app): {'issuer': ''}, {'issuer': 'not a url'}, {'display_name': True}, {'enabled': 'true'}, + {'id_token_response_type': 'true'}, {'code_response_type': 'true'}, + {'code_response_type': True, 'client_secret': ''}, + {'code_response_type': True, 'client_secret': True}, + {'code_response_type': True, 'client_secret': None}, + {'code_response_type': False, 'id_token_response_type': False}, ]) def test_update_invalid_args(self, user_mgt_app, invalid_opts): options = {'provider_id': 'oidc.provider'} @@ -182,12 +227,12 @@ def test_update(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' - mask = ['clientId', 'displayName', 'enabled', 'issuer'] - assert req.url == '{0}/oauthIdpConfigs/oidc.provider?updateMask={1}'.format( - USER_MGT_URL_PREFIX, ','.join(mask)) - got = json.loads(req.body.decode()) + mask = ['clientId', 'clientSecret', 'displayName', 'enabled', 'issuer', + 'responseType.code', 'responseType.idToken'] + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs/oidc.provider?' + f'updateMask={",".join(mask)}') + got = json.loads(recorder[0].body.decode()) assert got == self.OIDC_CONFIG_REQUEST def test_update_minimal(self, user_mgt_app): @@ -198,28 +243,27 @@ def test_update_minimal(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' - assert req.url == '{0}/oauthIdpConfigs/oidc.provider?updateMask=displayName'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs/oidc.provider?' + f'updateMask=displayName') + got = json.loads(recorder[0].body.decode()) assert got == {'displayName': 'oidcProviderName'} def test_update_empty_values(self, user_mgt_app): recorder = _instrument_provider_mgt(user_mgt_app, 200, OIDC_PROVIDER_CONFIG_RESPONSE) provider_config = auth.update_oidc_provider_config( - 'oidc.provider', display_name=auth.DELETE_ATTRIBUTE, enabled=False, app=user_mgt_app) + 'oidc.provider', display_name=auth.DELETE_ATTRIBUTE, enabled=False, + id_token_response_type=False, app=user_mgt_app) self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' - mask = ['displayName', 'enabled'] - assert req.url == '{0}/oauthIdpConfigs/oidc.provider?updateMask={1}'.format( - USER_MGT_URL_PREFIX, ','.join(mask)) - got = json.loads(req.body.decode()) - assert got == {'displayName': None, 'enabled': False} + mask = ['displayName', 'enabled', 'responseType.idToken'] + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs/oidc.provider?' + f'updateMask={",".join(mask)}') + got = json.loads(recorder[0].body.decode()) + assert got == {'displayName': None, 'enabled': False, 'responseType': {'idToken': False}} @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['saml.provider']) def test_delete_invalid_provider_id(self, user_mgt_app, provider_id): @@ -234,16 +278,15 @@ def test_delete(self, user_mgt_app): auth.delete_oidc_provider_config('oidc.provider', app=user_mgt_app) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'DELETE' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/oauthIdpConfigs/oidc.provider') + _assert_request(recorder[0], 'DELETE', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs/oidc.provider') - @pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 101, False]) + @pytest.mark.parametrize('arg', [None, 'foo', [], {}, 0, -1, 101, False]) def test_invalid_max_results(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_oidc_provider_configs(max_results=arg, app=user_mgt_app) - @pytest.mark.parametrize('arg', ['', list(), dict(), 0, -1, 101, False]) + @pytest.mark.parametrize('arg', ['', [], {}, 0, -1, 101, False]) def test_invalid_page_token(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_oidc_provider_configs(page_token=arg, app=user_mgt_app) @@ -257,9 +300,8 @@ def test_list_single_page(self, user_mgt_app): assert len(provider_configs) == 2 assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/oauthIdpConfigs?pageSize=100') + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=100') def test_list_multiple_pages(self, user_mgt_app): sample_response = json.loads(OIDC_PROVIDER_CONFIG_RESPONSE) @@ -275,9 +317,8 @@ def test_list_multiple_pages(self, user_mgt_app): self._assert_page(page, next_page_token='token') assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/oauthIdpConfigs?pageSize=10'.format(USER_MGT_URL_PREFIX) + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=10') # Page 2 (also the last page) response = {'oauthIdpConfigs': configs[2:]} @@ -286,10 +327,8 @@ def test_list_multiple_pages(self, user_mgt_app): self._assert_page(page, count=1, start=2) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/oauthIdpConfigs?pageSize=10&pageToken=token'.format( - USER_MGT_URL_PREFIX) + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=10&pageToken=token') def test_paged_iteration(self, user_mgt_app): sample_response = json.loads(OIDC_PROVIDER_CONFIG_RESPONSE) @@ -306,11 +345,10 @@ def test_paged_iteration(self, user_mgt_app): for index in range(2): provider_config = next(iterator) - assert provider_config.provider_id == 'oidc.provider{0}'.format(index) + assert provider_config.provider_id == f'oidc.provider{index}' assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/oauthIdpConfigs?pageSize=100'.format(USER_MGT_URL_PREFIX) + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=100') # Page 2 (also the last page) response = {'oauthIdpConfigs': configs[2:]} @@ -319,10 +357,8 @@ def test_paged_iteration(self, user_mgt_app): provider_config = next(iterator) assert provider_config.provider_id == 'oidc.provider2' assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/oauthIdpConfigs?pageSize=100&pageToken=token'.format( - USER_MGT_URL_PREFIX) + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=100&pageToken=token') with pytest.raises(StopIteration): next(iterator) @@ -366,7 +402,7 @@ def _assert_page(self, page, count=2, start=0, next_page_token=''): index = start assert len(page.provider_configs) == count for provider_config in page.provider_configs: - self._assert_provider_config(provider_config, want_id='oidc.provider{0}'.format(index)) + self._assert_provider_config(provider_config, want_id=f'oidc.provider{index}') index += 1 if next_page_token: @@ -419,9 +455,8 @@ def test_get(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/inboundSamlConfigs/saml.provider') + _assert_request(recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs/saml.provider') @pytest.mark.parametrize('invalid_opts', [ {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'oidc.provider'}, @@ -448,11 +483,10 @@ def test_create(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/inboundSamlConfigs?inboundSamlConfigId=saml.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?' + f'inboundSamlConfigId=saml.provider') + got = json.loads(recorder[0].body.decode()) assert got == self.SAML_CONFIG_REQUEST def test_create_minimal(self, user_mgt_app): @@ -468,11 +502,10 @@ def test_create_minimal(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/inboundSamlConfigs?inboundSamlConfigId=saml.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?' + f'inboundSamlConfigId=saml.provider') + got = json.loads(recorder[0].body.decode()) assert got == want def test_create_empty_values(self, user_mgt_app): @@ -488,11 +521,10 @@ def test_create_empty_values(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/inboundSamlConfigs?inboundSamlConfigId=saml.provider'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'POST', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?' + f'inboundSamlConfigId=saml.provider') + got = json.loads(recorder[0].body.decode()) assert got == want @pytest.mark.parametrize('invalid_opts', [ @@ -521,15 +553,14 @@ def test_update(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' mask = [ 'displayName', 'enabled', 'idpConfig.idpCertificates', 'idpConfig.idpEntityId', 'idpConfig.ssoUrl', 'spConfig.callbackUri', 'spConfig.spEntityId', ] - assert req.url == '{0}/inboundSamlConfigs/saml.provider?updateMask={1}'.format( - USER_MGT_URL_PREFIX, ','.join(mask)) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs/saml.provider?' + f'updateMask={",".join(mask)}') + got = json.loads(recorder[0].body.decode()) assert got == self.SAML_CONFIG_REQUEST def test_update_minimal(self, user_mgt_app): @@ -540,11 +571,10 @@ def test_update_minimal(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' - assert req.url == '{0}/inboundSamlConfigs/saml.provider?updateMask=displayName'.format( - USER_MGT_URL_PREFIX) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs/saml.provider?' + f'updateMask=displayName') + got = json.loads(recorder[0].body.decode()) assert got == {'displayName': 'samlProviderName'} def test_update_empty_values(self, user_mgt_app): @@ -555,12 +585,11 @@ def test_update_empty_values(self, user_mgt_app): self._assert_provider_config(provider_config) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'PATCH' mask = ['displayName', 'enabled'] - assert req.url == '{0}/inboundSamlConfigs/saml.provider?updateMask={1}'.format( - USER_MGT_URL_PREFIX, ','.join(mask)) - got = json.loads(req.body.decode()) + _assert_request(recorder[0], 'PATCH', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs/saml.provider?' + f'updateMask={",".join(mask)}') + got = json.loads(recorder[0].body.decode()) assert got == {'displayName': None, 'enabled': False} @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['oidc.provider']) @@ -576,9 +605,8 @@ def test_delete(self, user_mgt_app): auth.delete_saml_provider_config('saml.provider', app=user_mgt_app) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'DELETE' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/inboundSamlConfigs/saml.provider') + _assert_request( + recorder[0], 'DELETE', f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs/saml.provider') def test_config_not_found(self, user_mgt_app): _instrument_provider_mgt(user_mgt_app, 500, CONFIG_NOT_FOUND_RESPONSE) @@ -592,12 +620,12 @@ def test_config_not_found(self, user_mgt_app): assert excinfo.value.http_response is not None assert excinfo.value.cause is not None - @pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 101, False]) + @pytest.mark.parametrize('arg', [None, 'foo', [], {}, 0, -1, 101, False]) def test_invalid_max_results(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_saml_provider_configs(max_results=arg, app=user_mgt_app) - @pytest.mark.parametrize('arg', ['', list(), dict(), 0, -1, 101, False]) + @pytest.mark.parametrize('arg', ['', [], {}, 0, -1, 101, False]) def test_invalid_page_token(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_saml_provider_configs(page_token=arg, app=user_mgt_app) @@ -611,9 +639,8 @@ def test_list_single_page(self, user_mgt_app): assert len(provider_configs) == 2 assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, '/inboundSamlConfigs?pageSize=100') + _assert_request( + recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=100') def test_list_multiple_pages(self, user_mgt_app): sample_response = json.loads(SAML_PROVIDER_CONFIG_RESPONSE) @@ -629,9 +656,8 @@ def test_list_multiple_pages(self, user_mgt_app): self._assert_page(page, next_page_token='token') assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/inboundSamlConfigs?pageSize=10'.format(USER_MGT_URL_PREFIX) + _assert_request( + recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=10') # Page 2 (also the last page) response = {'inboundSamlConfigs': configs[2:]} @@ -640,10 +666,9 @@ def test_list_multiple_pages(self, user_mgt_app): self._assert_page(page, count=1, start=2) assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/inboundSamlConfigs?pageSize=10&pageToken=token'.format( - USER_MGT_URL_PREFIX) + _assert_request( + recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=10&pageToken=token') def test_paged_iteration(self, user_mgt_app): sample_response = json.loads(SAML_PROVIDER_CONFIG_RESPONSE) @@ -660,11 +685,10 @@ def test_paged_iteration(self, user_mgt_app): for index in range(2): provider_config = next(iterator) - assert provider_config.provider_id == 'saml.provider{0}'.format(index) + assert provider_config.provider_id == f'saml.provider{index}' assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/inboundSamlConfigs?pageSize=100'.format(USER_MGT_URL_PREFIX) + _assert_request( + recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=100') # Page 2 (also the last page) response = {'inboundSamlConfigs': configs[2:]} @@ -673,10 +697,9 @@ def test_paged_iteration(self, user_mgt_app): provider_config = next(iterator) assert provider_config.provider_id == 'saml.provider2' assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'GET' - assert req.url == '{0}/inboundSamlConfigs?pageSize=100&pageToken=token'.format( - USER_MGT_URL_PREFIX) + _assert_request( + recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=100&pageToken=token') with pytest.raises(StopIteration): next(iterator) @@ -711,7 +734,7 @@ def _assert_page(self, page, count=2, start=0, next_page_token=''): index = start assert len(page.provider_configs) == count for provider_config in page.provider_configs: - self._assert_provider_config(provider_config, want_id='saml.provider{0}'.format(index)) + self._assert_provider_config(provider_config, want_id=f'saml.provider{index}') index += 1 if next_page_token: diff --git a/tests/test_credentials.py b/tests/test_credentials.py index d78ef5192..1e1db6460 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -16,6 +16,7 @@ import datetime import json import os +import pathlib import google.auth from google.auth import crypt @@ -47,6 +48,12 @@ def test_init_from_file(self): testutils.resource_filename('service_account.json')) self._verify_credential(credential) + def test_init_from_path_like(self): + path = pathlib.Path(testutils.resource_filename('service_account.json')) + credential = credentials.Certificate(path) + self._verify_credential(credential) + + def test_init_from_dict(self): parsed_json = json.loads(testutils.resource('service_account.json')) credential = credentials.Certificate(parsed_json) @@ -57,7 +64,7 @@ def test_init_from_invalid_certificate(self, file_name, error): with pytest.raises(error): credentials.Certificate(testutils.resource_filename(file_name)) - @pytest.mark.parametrize('arg', [None, 0, 1, True, False, list(), tuple(), dict()]) + @pytest.mark.parametrize('arg', [None, 0, 1, True, False, [], tuple(), {}]) def test_invalid_args(self, arg): with pytest.raises(ValueError): credentials.Certificate(arg) @@ -129,6 +136,11 @@ def test_init_from_file(self): testutils.resource_filename('refresh_token.json')) self._verify_credential(credential) + def test_init_from_path_like(self): + path = pathlib.Path(testutils.resource_filename('refresh_token.json')) + credential = credentials.RefreshToken(path) + self._verify_credential(credential) + def test_init_from_dict(self): parsed_json = json.loads(testutils.resource('refresh_token.json')) credential = credentials.RefreshToken(parsed_json) @@ -144,7 +156,7 @@ def test_init_from_invalid_file(self): credentials.RefreshToken( testutils.resource_filename('service_account.json')) - @pytest.mark.parametrize('arg', [None, 0, 1, True, False, list(), tuple(), dict()]) + @pytest.mark.parametrize('arg', [None, 0, 1, True, False, [], tuple(), {}]) def test_invalid_args(self, arg): with pytest.raises(ValueError): credentials.RefreshToken(arg) diff --git a/tests/test_db.py b/tests/test_db.py index 2989fc030..abba3baa8 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -15,6 +15,7 @@ """Tests for firebase_admin.db.""" import collections import json +import os import sys import time @@ -25,9 +26,13 @@ from firebase_admin import exceptions from firebase_admin import _http_client from firebase_admin import _sseclient +from firebase_admin import _utils from tests import testutils +_EMULATOR_HOST_ENV_VAR = 'FIREBASE_DATABASE_EMULATOR_HOST' + + class MockAdapter(testutils.MockAdapter): """A mock HTTP adapter that mimics RTDB server behavior.""" @@ -40,7 +45,7 @@ def __init__(self, data, status, recorder, etag=ETAG): def send(self, request, **kwargs): if_match = request.headers.get('if-match') if_none_match = request.headers.get('if-none-match') - resp = super(MockAdapter, self).send(request, **kwargs) + resp = super().send(request, **kwargs) resp.headers = {'ETag': self._etag} if if_match and if_match != MockAdapter.ETAG: resp.status_code = 412 @@ -82,7 +87,7 @@ class TestReferencePath: } invalid_paths = [ - None, True, False, 0, 1, dict(), list(), tuple(), _Object(), + None, True, False, 0, 1, {}, [], tuple(), _Object(), 'foo#', 'foo.', 'foo$', 'foo[', 'foo]', ] @@ -93,7 +98,7 @@ class TestReferencePath: } invalid_children = [ - None, '', '/foo', '/foo/bar', True, False, 0, 1, dict(), list(), tuple(), + None, '', '/foo', '/foo/bar', True, False, 0, 1, {}, [], tuple(), 'foo#', 'foo.', 'foo$', 'foo[', 'foo]', _Object() ] @@ -188,16 +193,21 @@ def instrument(self, ref, payload, status=200, etag=MockAdapter.ETAG): ref._client.session.mount(self.test_url, adapter) return recorder + def _assert_request(self, request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['Authorization'] == 'Bearer mock-token' + assert request.headers['User-Agent'] == db._USER_AGENT + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + @pytest.mark.parametrize('data', valid_values) def test_get_value(self, data): ref = db.reference('/test') recorder = self.instrument(ref, json.dumps(data)) assert ref.get() == data assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test.json') assert 'X-Firebase-ETag' not in recorder[0].headers @pytest.mark.parametrize('data', valid_values) @@ -206,10 +216,7 @@ def test_get_with_etag(self, data): recorder = self.instrument(ref, json.dumps(data)) assert ref.get(etag=True) == (data, MockAdapter.ETAG) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test.json') assert recorder[0].headers['X-Firebase-ETag'] == 'true' @pytest.mark.parametrize('data', valid_values) @@ -218,10 +225,8 @@ def test_get_shallow(self, data): recorder = self.instrument(ref, json.dumps(data)) assert ref.get(shallow=True) == data assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?shallow=true' - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?shallow=true') def test_get_with_etag_and_shallow(self): ref = db.reference('/test') @@ -235,17 +240,15 @@ def test_get_if_changed(self, data): assert ref.get_if_changed('invalid-etag') == (True, data, MockAdapter.ETAG) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test.json') assert recorder[0].headers['if-none-match'] == 'invalid-etag' assert ref.get_if_changed(MockAdapter.ETAG) == (False, None, None) assert len(recorder) == 2 - assert recorder[1].method == 'GET' - assert recorder[1].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[1], 'GET', 'https://test.firebaseio.com/test.json') assert recorder[1].headers['if-none-match'] == MockAdapter.ETAG - @pytest.mark.parametrize('etag', [0, 1, True, False, dict(), list(), tuple()]) + @pytest.mark.parametrize('etag', [0, 1, True, False, {}, [], tuple()]) def test_get_if_changed_invalid_etag(self, etag): ref = db.reference('/test') with pytest.raises(ValueError): @@ -259,9 +262,8 @@ def test_order_by_query(self, data): query_str = 'orderBy=%22foo%22' assert query.get() == data assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) @pytest.mark.parametrize('data', valid_values) def test_limit_query(self, data): @@ -272,9 +274,8 @@ def test_limit_query(self, data): query_str = 'limitToFirst=100&orderBy=%22foo%22' assert query.get() == data assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) @pytest.mark.parametrize('data', valid_values) def test_range_query(self, data): @@ -286,9 +287,8 @@ def test_range_query(self, data): query_str = 'endAt=200&orderBy=%22foo%22&startAt=100' assert query.get() == data assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) @pytest.mark.parametrize('data', valid_values) def test_set_value(self, data): @@ -296,10 +296,9 @@ def test_set_value(self, data): recorder = self.instrument(ref, '') ref.set(data) assert len(recorder) == 1 - assert recorder[0].method == 'PUT' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?print=silent' + self._assert_request( + recorder[0], 'PUT', 'https://test.firebaseio.com/test.json?print=silent') assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' def test_set_none_value(self): ref = db.reference('/test') @@ -322,10 +321,9 @@ def test_update_children(self, data): recorder = self.instrument(ref, json.dumps(data)) ref.update(data) assert len(recorder) == 1 - assert recorder[0].method == 'PATCH' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?print=silent' + self._assert_request( + recorder[0], 'PATCH', 'https://test.firebaseio.com/test.json?print=silent') assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' @pytest.mark.parametrize('data', valid_values) def test_set_if_unchanged_success(self, data): @@ -334,10 +332,8 @@ def test_set_if_unchanged_success(self, data): vals = ref.set_if_unchanged(MockAdapter.ETAG, data) assert vals == (True, data, MockAdapter.ETAG) assert len(recorder) == 1 - assert recorder[0].method == 'PUT' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[0], 'PUT', 'https://test.firebaseio.com/test.json') assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' assert recorder[0].headers['if-match'] == MockAdapter.ETAG @pytest.mark.parametrize('data', valid_values) @@ -347,13 +343,11 @@ def test_set_if_unchanged_failure(self, data): vals = ref.set_if_unchanged('invalid-etag', data) assert vals == (False, {'foo':'bar'}, MockAdapter.ETAG) assert len(recorder) == 1 - assert recorder[0].method == 'PUT' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[0], 'PUT', 'https://test.firebaseio.com/test.json') assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' assert recorder[0].headers['if-match'] == 'invalid-etag' - @pytest.mark.parametrize('etag', [0, 1, True, False, dict(), list(), tuple()]) + @pytest.mark.parametrize('etag', [0, 1, True, False, {}, [], tuple()]) def test_set_if_unchanged_invalid_etag(self, etag): ref = db.reference('/test') with pytest.raises(ValueError): @@ -375,7 +369,7 @@ def test_set_if_unchanged_non_json_value(self, value): ref.set_if_unchanged(MockAdapter.ETAG, value) @pytest.mark.parametrize('update', [ - None, {}, {None:'foo'}, '', 'foo', 0, 1, list(), tuple(), _Object() + None, {}, {None:'foo'}, '', 'foo', 0, 1, [], tuple(), _Object() ]) def test_set_invalid_update(self, update): ref = db.reference('/test') @@ -392,22 +386,16 @@ def test_push(self, data): assert isinstance(child, db.Reference) assert child.key == 'testkey' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[0], 'POST', 'https://test.firebaseio.com/test.json') assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT def test_push_default(self): ref = db.reference('/test') recorder = self.instrument(ref, json.dumps({'name' : 'testkey'})) assert ref.push().key == 'testkey' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' + self._assert_request(recorder[0], 'POST', 'https://test.firebaseio.com/test.json') assert json.loads(recorder[0].body.decode()) == '' - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT def test_push_none_value(self): ref = db.reference('/test') @@ -420,10 +408,7 @@ def test_delete(self): recorder = self.instrument(ref, '') ref.delete() assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == 'https://test.firebaseio.com/test.json' - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request(recorder[0], 'DELETE', 'https://test.firebaseio.com/test.json') def test_transaction(self): ref = db.reference('/test') @@ -437,8 +422,8 @@ def transaction_update(data): new_value = ref.transaction(transaction_update) assert new_value == {'foo1' : 'bar1', 'foo2' : 'bar2'} assert len(recorder) == 2 - assert recorder[0].method == 'GET' - assert recorder[1].method == 'PUT' + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test.json') + self._assert_request(recorder[1], 'PUT', 'https://test.firebaseio.com/test.json') assert json.loads(recorder[1].body.decode()) == {'foo1': 'bar1', 'foo2': 'bar2'} def test_transaction_scalar(self): @@ -449,8 +434,8 @@ def test_transaction_scalar(self): new_value = ref.transaction(lambda x: x + 1 if x else 1) assert new_value == 43 assert len(recorder) == 2 - assert recorder[0].method == 'GET' - assert recorder[1].method == 'PUT' + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test/count.json') + self._assert_request(recorder[1], 'PUT', 'https://test.firebaseio.com/test/count.json') assert json.loads(recorder[1].body.decode()) == 43 def test_transaction_error(self): @@ -466,7 +451,7 @@ def transaction_update(data): ref.transaction(transaction_update) assert str(excinfo.value) == 'test error' assert len(recorder) == 1 - assert recorder[0].method == 'GET' + self._assert_request(recorder[0], 'GET', 'https://test.firebaseio.com/test.json') def test_transaction_abort(self): ref = db.reference('/test/count') @@ -481,7 +466,7 @@ def test_transaction_abort(self): assert excinfo.value.http_response is None assert len(recorder) == 1 + 25 - @pytest.mark.parametrize('func', [None, 0, 1, True, False, 'foo', dict(), list(), tuple()]) + @pytest.mark.parametrize('func', [None, 0, 1, True, False, 'foo', {}, [], tuple()]) def test_transaction_invalid_function(self, func): ref = db.reference('/test') with pytest.raises(ValueError): @@ -551,6 +536,49 @@ def callback(_): finally: testutils.cleanup_apps() + @pytest.mark.parametrize( + 'url,emulator_host,expected_base_url,expected_namespace', + [ + # Production URLs with no override: + ('https://test.firebaseio.com', None, 'https://test.firebaseio.com/.json', None), + ('https://test.firebaseio.com/', None, 'https://test.firebaseio.com/.json', None), + + # Production URLs with emulator_host override: + ('https://test.firebaseio.com', 'localhost:9000', 'http://localhost:9000/.json', + 'test'), + ('https://test.firebaseio.com/', 'localhost:9000', 'http://localhost:9000/.json', + 'test'), + + # Emulator URL with no override. + ('http://localhost:8000/?ns=test', None, 'http://localhost:8000/.json', 'test'), + + # emulator_host is ignored when the original URL is already emulator. + ('http://localhost:8000/?ns=test', 'localhost:9999', 'http://localhost:8000/.json', + 'test'), + ] + ) + def test_listen_sse_client(self, url, emulator_host, expected_base_url, expected_namespace, + mocker): + if emulator_host: + os.environ[_EMULATOR_HOST_ENV_VAR] = emulator_host + + try: + firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url}) + ref = db.reference() + mock_sse_client = mocker.patch('firebase_admin._sseclient.SSEClient') + mock_callback = mocker.Mock() + ref.listen(mock_callback) + args, kwargs = mock_sse_client.call_args + assert args[0] == expected_base_url + if expected_namespace: + assert kwargs.get('params') == {'ns': expected_namespace} + else: + assert kwargs.get('params') == {} + finally: + if _EMULATOR_HOST_ENV_VAR in os.environ: + del os.environ[_EMULATOR_HOST_ENV_VAR] + testutils.cleanup_apps() + def test_listener_session(self): firebase_admin.initialize_app(testutils.MockCredential(), { 'databaseURL' : 'https://test.firebaseio.com', @@ -633,54 +661,55 @@ def instrument(self, ref, payload, status=200): ref._client.session.mount(self.test_url, adapter) return recorder + def _assert_request(self, request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['Authorization'] == 'Bearer mock-token' + assert request.headers['User-Agent'] == db._USER_AGENT + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + def test_get_value(self): ref = db.reference('/test') recorder = self.instrument(ref, json.dumps('data')) - query_str = 'auth_variable_override={0}'.format(self.encoded_override) + query_str = f'auth_variable_override={self.encoded_override}' assert ref.get() == 'data' assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) def test_set_value(self): ref = db.reference('/test') recorder = self.instrument(ref, '') data = {'foo' : 'bar'} ref.set(data) - query_str = 'print=silent&auth_variable_override={0}'.format(self.encoded_override) + query_str = f'print=silent&auth_variable_override={self.encoded_override}' assert len(recorder) == 1 - assert recorder[0].method == 'PUT' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str + self._assert_request( + recorder[0], 'PUT', 'https://test.firebaseio.com/test.json?' + query_str) assert json.loads(recorder[0].body.decode()) == data - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT def test_order_by_query(self): ref = db.reference('/test') recorder = self.instrument(ref, json.dumps('data')) query = ref.order_by_child('foo') - query_str = 'orderBy=%22foo%22&auth_variable_override={0}'.format(self.encoded_override) + query_str = f'orderBy=%22foo%22&auth_variable_override={self.encoded_override}' assert query.get() == 'data' assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) def test_range_query(self): ref = db.reference('/test') recorder = self.instrument(ref, json.dumps('data')) query = ref.order_by_child('foo').start_at(1).end_at(10) - query_str = ('endAt=10&orderBy=%22foo%22&startAt=1&' - 'auth_variable_override={0}'.format(self.encoded_override)) + query_str = ( + f'endAt=10&orderBy=%22foo%22&startAt=1&auth_variable_override={self.encoded_override}' + ) assert query.get() == 'data' assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == 'https://test.firebaseio.com/test.json?' + query_str - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - assert recorder[0].headers['User-Agent'] == db._USER_AGENT + self._assert_request( + recorder[0], 'GET', 'https://test.firebaseio.com/test.json?' + query_str) class TestDatabaseInitialization: @@ -702,53 +731,71 @@ def test_no_db_url(self): 'url,emulator_host,expected_base_url,expected_namespace', [ # Production URLs with no override: - ('https://test.firebaseio.com', None, 'https://test.firebaseio.com', 'test'), - ('https://test.firebaseio.com/', None, 'https://test.firebaseio.com', 'test'), + ('https://test.firebaseio.com', None, 'https://test.firebaseio.com', None), + ('https://test.firebaseio.com/', None, 'https://test.firebaseio.com', None), # Production URLs with emulator_host override: ('https://test.firebaseio.com', 'localhost:9000', 'http://localhost:9000', 'test'), ('https://test.firebaseio.com/', 'localhost:9000', 'http://localhost:9000', 'test'), - # Emulator URLs with no override. + # Emulator URL with no override. ('http://localhost:8000/?ns=test', None, 'http://localhost:8000', 'test'), + # emulator_host is ignored when the original URL is already emulator. ('http://localhost:8000/?ns=test', 'localhost:9999', 'http://localhost:8000', 'test'), ] ) def test_parse_db_url(self, url, emulator_host, expected_base_url, expected_namespace): - base_url, namespace = db._DatabaseService._parse_db_url(url, emulator_host) - assert base_url == expected_base_url - assert namespace == expected_namespace - - @pytest.mark.parametrize('url,emulator_host', [ - ('', None), - (None, None), - (42, None), - ('test.firebaseio.com', None), # Not a URL. - ('http://test.firebaseio.com', None), # Use of non-HTTPs in production URLs. - ('ftp://test.firebaseio.com', None), # Use of non-HTTPs in production URLs. - ('https://example.com', None), # Invalid RTDB URL. - ('http://localhost:9000/', None), # No ns specified. - ('http://localhost:9000/?ns=', None), # No ns specified. - ('http://localhost:9000/?ns=test1&ns=test2', None), # Two ns parameters specified. - ('ftp://localhost:9000/?ns=test', None), # Neither HTTP or HTTPS. + if emulator_host: + os.environ[_EMULATOR_HOST_ENV_VAR] = emulator_host + + try: + firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url}) + ref = db.reference() + assert ref._client._base_url == expected_base_url + assert ref._client.params.get('ns') == expected_namespace + if expected_base_url.startswith('http://localhost'): + assert isinstance(ref._client.credential, _utils.EmulatorAdminCredentials) + else: + assert isinstance(ref._client.credential, testutils.MockGoogleCredential) + finally: + if _EMULATOR_HOST_ENV_VAR in os.environ: + del os.environ[_EMULATOR_HOST_ENV_VAR] + + @pytest.mark.parametrize('url', [ + '', + None, + 42, + 'test.firebaseio.com', # Not a URL. + 'http://test.firebaseio.com', # Use of non-HTTPs in production URLs. + 'ftp://test.firebaseio.com', # Use of non-HTTPs in production URLs. + 'http://localhost:9000/', # No ns specified. + 'http://localhost:9000/?ns=', # No ns specified. + 'http://localhost:9000/?ns=test1&ns=test2', # Two ns parameters specified. + 'ftp://localhost:9000/?ns=test', # Neither HTTP or HTTPS. ]) - def test_parse_db_url_errors(self, url, emulator_host): + def test_parse_db_url_errors(self, url): + firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url}) with pytest.raises(ValueError): - db._DatabaseService._parse_db_url(url, emulator_host) + db.reference() @pytest.mark.parametrize('url', [ - 'https://test.firebaseio.com', 'https://test.firebaseio.com/' + 'https://test.firebaseio.com', 'https://test.firebaseio.com/', + 'https://test.eu-west1.firebasdatabase.app', 'https://test.eu-west1.firebasdatabase.app/' ]) def test_valid_db_url(self, url): firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url}) ref = db.reference() - assert ref._client.base_url == 'https://test.firebaseio.com' + expected_url = url + if url.endswith('/'): + expected_url = url[:-1] + assert ref._client.base_url == expected_url assert 'auth_variable_override' not in ref._client.params + assert 'ns' not in ref._client.params @pytest.mark.parametrize('url', [ - None, '', 'foo', 'http://test.firebaseio.com', 'https://google.com', - True, False, 1, 0, dict(), list(), tuple(), _Object() + None, '', 'foo', 'http://test.firebaseio.com', 'http://test.firebasedatabase.app', + True, False, 1, 0, {}, [], tuple(), _Object() ]) def test_invalid_db_url(self, url): firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url}) @@ -792,7 +839,7 @@ def test_valid_auth_override(self, override): assert ref._client.params['auth_variable_override'] == encoded @pytest.mark.parametrize('override', [ - '', 'foo', 0, 1, True, False, list(), tuple(), _Object()]) + '', 'foo', 0, 1, True, False, [], tuple(), _Object()]) def test_invalid_auth_override(self, override): firebase_admin.initialize_app(testutils.MockCredential(), { 'databaseURL' : 'https://test.firebaseio.com', @@ -839,8 +886,10 @@ def test_app_delete(self): assert other_ref._client.session is None def test_user_agent_format(self): - expected = 'Firebase/HTTP/{0}/{1}.{2}/AdminPython'.format( - firebase_admin.__version__, sys.version_info.major, sys.version_info.minor) + expected = ( + f'Firebase/HTTP/{firebase_admin.__version__}/{sys.version_info.major}.' + f'{sys.version_info.minor}/AdminPython' + ) assert db._USER_AGENT == expected def _check_timeout(self, ref, timeout): @@ -879,7 +928,7 @@ class TestQuery: ref = db.Reference(path='foo') @pytest.mark.parametrize('path', [ - '', None, '/', '/foo', 0, 1, True, False, dict(), list(), tuple(), _Object(), + '', None, '/', '/foo', 0, 1, True, False, {}, [], tuple(), _Object(), '$foo', '.foo', '#foo', '[foo', 'foo]', '$key', '$value', '$priority' ]) def test_invalid_path(self, path): @@ -889,13 +938,13 @@ def test_invalid_path(self, path): @pytest.mark.parametrize('path, expected', valid_paths.items()) def test_order_by_valid_path(self, path, expected): query = self.ref.order_by_child(path) - assert query._querystr == 'orderBy="{0}"'.format(expected) + assert query._querystr == f'orderBy="{expected}"' @pytest.mark.parametrize('path, expected', valid_paths.items()) def test_filter_by_valid_path(self, path, expected): query = self.ref.order_by_child(path) query.equal_to(10) - assert query._querystr == 'equalTo=10&orderBy="{0}"'.format(expected) + assert query._querystr == f'equalTo=10&orderBy="{expected}"' def test_order_by_key(self): query = self.ref.order_by_key() @@ -926,7 +975,7 @@ def test_multiple_limits(self): with pytest.raises(ValueError): query.limit_to_first(1) - @pytest.mark.parametrize('limit', [None, -1, 'foo', 1.2, list(), dict(), tuple(), _Object()]) + @pytest.mark.parametrize('limit', [None, -1, 'foo', 1.2, [], {}, tuple(), _Object()]) def test_invalid_limit(self, limit): query = self.ref.order_by_child('foo') with pytest.raises(ValueError): @@ -939,47 +988,47 @@ def test_start_at_none(self): with pytest.raises(ValueError): query.start_at(None) - @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, dict()]) + @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, {}]) def test_valid_start_at(self, arg): query = self.ref.order_by_child('foo').start_at(arg) - assert query._querystr == 'orderBy="foo"&startAt={0}'.format(json.dumps(arg)) + assert query._querystr == f'orderBy="foo"&startAt={json.dumps(arg)}' def test_end_at_none(self): query = self.ref.order_by_child('foo') with pytest.raises(ValueError): query.end_at(None) - @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, dict()]) + @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, {}]) def test_valid_end_at(self, arg): query = self.ref.order_by_child('foo').end_at(arg) - assert query._querystr == 'endAt={0}&orderBy="foo"'.format(json.dumps(arg)) + assert query._querystr == f'endAt={json.dumps(arg)}&orderBy="foo"' def test_equal_to_none(self): query = self.ref.order_by_child('foo') with pytest.raises(ValueError): query.equal_to(None) - @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, dict()]) + @pytest.mark.parametrize('arg', ['', 'foo', True, False, 0, 1, {}]) def test_valid_equal_to(self, arg): query = self.ref.order_by_child('foo').equal_to(arg) - assert query._querystr == 'equalTo={0}&orderBy="foo"'.format(json.dumps(arg)) + assert query._querystr == f'equalTo={json.dumps(arg)}&orderBy="foo"' def test_range_query(self, initquery): query, order_by = initquery query.start_at(1) query.equal_to(2) query.end_at(3) - assert query._querystr == 'endAt=3&equalTo=2&orderBy="{0}"&startAt=1'.format(order_by) + assert query._querystr == f'endAt=3&equalTo=2&orderBy="{order_by}"&startAt=1' def test_limit_first_query(self, initquery): query, order_by = initquery query.limit_to_first(1) - assert query._querystr == 'limitToFirst=1&orderBy="{0}"'.format(order_by) + assert query._querystr == f'limitToFirst=1&orderBy="{order_by}"' def test_limit_last_query(self, initquery): query, order_by = initquery query.limit_to_last(1) - assert query._querystr == 'limitToLast=1&orderBy="{0}"'.format(order_by) + assert query._querystr == f'limitToLast=1&orderBy="{order_by}"' def test_all_in(self, initquery): query, order_by = initquery @@ -987,7 +1036,7 @@ def test_all_in(self, initquery): query.equal_to(2) query.end_at(3) query.limit_to_first(10) - expected = 'endAt=3&equalTo=2&limitToFirst=10&orderBy="{0}"&startAt=1'.format(order_by) + expected = f'endAt=3&equalTo=2&limitToFirst=10&orderBy="{order_by}"&startAt=1' assert query._querystr == expected def test_invalid_query_args(self): @@ -1013,9 +1062,9 @@ class TestSorter: ({'k1' : False, 'k2' : 'bar', 'k3' : None}, ['k3', 'k1', 'k2']), ({'k1' : False, 'k2' : 1, 'k3' : None}, ['k3', 'k1', 'k2']), ({'k1' : True, 'k2' : 0, 'k3' : None, 'k4' : 'foo'}, ['k3', 'k1', 'k2', 'k4']), - ({'k1' : True, 'k2' : 0, 'k3' : None, 'k4' : 'foo', 'k5' : False, 'k6' : dict()}, + ({'k1' : True, 'k2' : 0, 'k3' : None, 'k4' : 'foo', 'k5' : False, 'k6' : {}}, ['k3', 'k5', 'k1', 'k2', 'k4', 'k6']), - ({'k1' : True, 'k2' : 0, 'k3' : 'foo', 'k4' : 'foo', 'k5' : False, 'k6' : dict()}, + ({'k1' : True, 'k2' : 0, 'k3' : 'foo', 'k4' : 'foo', 'k5' : False, 'k6' : {}}, ['k5', 'k1', 'k2', 'k3', 'k4', 'k6']), ] diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 96072d91b..fa1276feb 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -14,14 +14,10 @@ import io import json -import socket -import httplib2 -import pytest import requests from requests import models -from googleapiclient import errors from firebase_admin import exceptions from firebase_admin import _utils @@ -177,157 +173,3 @@ def _create_response(self, status=500, payload=None): resp.raw = io.BytesIO(payload.encode()) exc = requests.exceptions.RequestException('Test error', response=resp) return resp, exc - - -class TestGoogleApiClient: - - @pytest.mark.parametrize('error', [ - socket.timeout('Test error'), - socket.error('Read timed out') - ]) - def test_googleapicleint_timeout_error(self, error): - firebase_error = _utils.handle_googleapiclient_error(error) - assert isinstance(firebase_error, exceptions.DeadlineExceededError) - assert str(firebase_error) == 'Timed out while making an API call: {0}'.format(error) - assert firebase_error.cause is error - assert firebase_error.http_response is None - - def test_googleapiclient_connection_error(self): - error = httplib2.ServerNotFoundError('Test error') - firebase_error = _utils.handle_googleapiclient_error(error) - assert isinstance(firebase_error, exceptions.UnavailableError) - assert str(firebase_error) == 'Failed to establish a connection: Test error' - assert firebase_error.cause is error - assert firebase_error.http_response is None - - def test_unknown_transport_error(self): - error = socket.error('Test error') - firebase_error = _utils.handle_googleapiclient_error(error) - assert isinstance(firebase_error, exceptions.UnknownError) - assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' - assert firebase_error.cause is error - assert firebase_error.http_response is None - - def test_http_response(self): - error = self._create_http_error() - firebase_error = _utils.handle_googleapiclient_error(error) - assert isinstance(firebase_error, exceptions.InternalError) - assert str(firebase_error) == str(error) - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == 'Body' - - def test_http_response_with_unknown_status(self): - error = self._create_http_error(status=501) - firebase_error = _utils.handle_googleapiclient_error(error) - assert isinstance(firebase_error, exceptions.UnknownError) - assert str(firebase_error) == str(error) - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 501 - assert firebase_error.http_response.content.decode() == 'Body' - - def test_http_response_with_message(self): - error = self._create_http_error() - firebase_error = _utils.handle_googleapiclient_error( - error, message='Explicit error message') - assert isinstance(firebase_error, exceptions.InternalError) - assert str(firebase_error) == 'Explicit error message' - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == 'Body' - - def test_http_response_with_code(self): - error = self._create_http_error() - firebase_error = _utils.handle_googleapiclient_error( - error, code=exceptions.UNAVAILABLE) - assert isinstance(firebase_error, exceptions.UnavailableError) - assert str(firebase_error) == str(error) - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == 'Body' - - def test_http_response_with_message_and_code(self): - error = self._create_http_error() - firebase_error = _utils.handle_googleapiclient_error( - error, message='Explicit error message', code=exceptions.UNAVAILABLE) - assert isinstance(firebase_error, exceptions.UnavailableError) - assert str(firebase_error) == 'Explicit error message' - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == 'Body' - - def test_handle_platform_error(self): - error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) - firebase_error = _utils.handle_platform_error_from_googleapiclient(error) - assert isinstance(firebase_error, exceptions.NotFoundError) - assert str(firebase_error) == 'test error' - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD - - def test_handle_platform_error_with_no_response(self): - error = socket.error('Test error') - firebase_error = _utils.handle_platform_error_from_googleapiclient(error) - assert isinstance(firebase_error, exceptions.UnknownError) - assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' - assert firebase_error.cause is error - assert firebase_error.http_response is None - - def test_handle_platform_error_with_no_error_code(self): - error = self._create_http_error(payload='no error code') - firebase_error = _utils.handle_platform_error_from_googleapiclient(error) - assert isinstance(firebase_error, exceptions.InternalError) - message = 'Unexpected HTTP response with status: 500; body: no error code' - assert str(firebase_error) == message - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == 'no error code' - - def test_handle_platform_error_with_custom_handler(self): - error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) - invocations = [] - - def _custom_handler(cause, message, error_dict, http_response): - invocations.append((cause, message, error_dict, http_response)) - return exceptions.InvalidArgumentError('Custom message', cause, http_response) - - firebase_error = _utils.handle_platform_error_from_googleapiclient(error, _custom_handler) - - assert isinstance(firebase_error, exceptions.InvalidArgumentError) - assert str(firebase_error) == 'Custom message' - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD - assert len(invocations) == 1 - args = invocations[0] - assert len(args) == 4 - assert args[0] is error - assert args[1] == 'test error' - assert args[2] == _NOT_FOUND_ERROR_DICT - assert args[3] is not None - - def test_handle_platform_error_with_custom_handler_ignore(self): - error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) - invocations = [] - - def _custom_handler(cause, message, error_dict, http_response): - invocations.append((cause, message, error_dict, http_response)) - - firebase_error = _utils.handle_platform_error_from_googleapiclient(error, _custom_handler) - - assert isinstance(firebase_error, exceptions.NotFoundError) - assert str(firebase_error) == 'test error' - assert firebase_error.cause is error - assert firebase_error.http_response.status_code == 500 - assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD - assert len(invocations) == 1 - args = invocations[0] - assert len(args) == 4 - assert args[0] is error - assert args[1] == 'test error' - assert args[2] == _NOT_FOUND_ERROR_DICT - assert args[3] is not None - - def _create_http_error(self, status=500, payload='Body'): - resp = httplib2.Response({'status': status}) - return errors.HttpError(resp, payload.encode()) diff --git a/tests/test_firestore.py b/tests/test_firestore.py index 768eb637e..47debd54b 100644 --- a/tests/test_firestore.py +++ b/tests/test_firestore.py @@ -50,6 +50,7 @@ def test_project_id(self): client = firestore.client() assert client is not None assert client.project == 'explicit-project-id' + assert client._database == '(default)' def test_project_id_with_explicit_app(self): cred = credentials.Certificate(testutils.resource_filename('service_account.json')) @@ -57,6 +58,7 @@ def test_project_id_with_explicit_app(self): client = firestore.client(app=app) assert client is not None assert client.project == 'explicit-project-id' + assert client._database == '(default)' def test_service_account(self): cred = credentials.Certificate(testutils.resource_filename('service_account.json')) @@ -64,6 +66,7 @@ def test_service_account(self): client = firestore.client() assert client is not None assert client.project == 'mock-project-id' + assert client._database == '(default)' def test_service_account_with_explicit_app(self): cred = credentials.Certificate(testutils.resource_filename('service_account.json')) @@ -71,6 +74,89 @@ def test_service_account_with_explicit_app(self): client = firestore.client(app=app) assert client is not None assert client.project == 'mock-project-id' + assert client._database == '(default)' + + @pytest.mark.parametrize('database_id', [123, False, True, {}, []]) + def test_invalid_database_id(self, database_id): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + with pytest.raises(ValueError) as excinfo: + firestore.client(database_id=database_id) + assert str(excinfo.value) == f'database_id "{database_id}" must be a string or None.' + + def test_database_id(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client = firestore.client(database_id=database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == 'mock-database-id' + + @pytest.mark.parametrize('database_id', ['', '(default)', None]) + def test_database_id_with_default_id(self, database_id): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + client = firestore.client(database_id=database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == '(default)' + + def test_database_id_with_explicit_app(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + app = firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client = firestore.client(app, database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == 'mock-database-id' + + def test_database_id_with_multi_db(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id_1 = 'mock-database-id-1' + database_id_2 = 'mock-database-id-2' + client_1 = firestore.client(database_id=database_id_1) + client_2 = firestore.client(database_id=database_id_2) + assert (client_1 is not None) and (client_2 is not None) + assert client_1 is not client_2 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_1._database == 'mock-database-id-1' + assert client_2._database == 'mock-database-id-2' + + def test_database_id_with_multi_db_uses_cache(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client_1 = firestore.client(database_id=database_id) + client_2 = firestore.client(database_id=database_id) + assert (client_1 is not None) and (client_2 is not None) + assert client_1 is client_2 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_1._database == 'mock-database-id' + assert client_2._database == 'mock-database-id' + + def test_database_id_with_multi_db_uses_cache_default(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id_1 = '' + database_id_2 = '(default)' + client_1 = firestore.client(database_id=database_id_1) + client_2 = firestore.client(database_id=database_id_2) + client_3 = firestore.client() + assert (client_1 is not None) and (client_2 is not None) and (client_3 is not None) + assert client_1 is client_2 + assert client_1 is client_3 + assert client_2 is client_3 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_3.project == 'mock-project-id' + assert client_1._database == '(default)' + assert client_2._database == '(default)' + assert client_3._database == '(default)' + def test_geo_point(self): geo_point = firestore.GeoPoint(10, 20) # pylint: disable=no-member diff --git a/tests/test_firestore_async.py b/tests/test_firestore_async.py new file mode 100644 index 000000000..3d17cbfc5 --- /dev/null +++ b/tests/test_firestore_async.py @@ -0,0 +1,167 @@ +# Copyright 2022 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for firebase_admin.firestore_async.""" + +import platform + +import pytest + +import firebase_admin +from firebase_admin import credentials +try: + from firebase_admin import firestore_async +except ImportError: + pass +from tests import testutils + + +@pytest.mark.skipif( + platform.python_implementation() == 'PyPy', + reason='Firestore is not supported on PyPy') +class TestFirestoreAsync: + """Test class Firestore Async APIs.""" + + def teardown_method(self, method): + del method + testutils.cleanup_apps() + + def test_no_project_id(self): + def evaluate(): + firebase_admin.initialize_app(testutils.MockCredential()) + with pytest.raises(ValueError): + firestore_async.client() + testutils.run_without_project_id(evaluate) + + def test_project_id(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + client = firestore_async.client() + assert client is not None + assert client.project == 'explicit-project-id' + assert client._database == '(default)' + + def test_project_id_with_explicit_app(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + client = firestore_async.client(app=app) + assert client is not None + assert client.project == 'explicit-project-id' + assert client._database == '(default)' + + def test_service_account(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + client = firestore_async.client() + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == '(default)' + + def test_service_account_with_explicit_app(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + app = firebase_admin.initialize_app(cred) + client = firestore_async.client(app=app) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == '(default)' + + @pytest.mark.parametrize('database_id', [123, False, True, {}, []]) + def test_invalid_database_id(self, database_id): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + with pytest.raises(ValueError) as excinfo: + firestore_async.client(database_id=database_id) + assert str(excinfo.value) == f'database_id "{database_id}" must be a string or None.' + + def test_database_id(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client = firestore_async.client(database_id=database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == 'mock-database-id' + + @pytest.mark.parametrize('database_id', ['', '(default)', None]) + def test_database_id_with_default_id(self, database_id): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + client = firestore_async.client(database_id=database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == '(default)' + + def test_database_id_with_explicit_app(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + app = firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client = firestore_async.client(app, database_id) + assert client is not None + assert client.project == 'mock-project-id' + assert client._database == 'mock-database-id' + + def test_database_id_with_multi_db(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id_1 = 'mock-database-id-1' + database_id_2 = 'mock-database-id-2' + client_1 = firestore_async.client(database_id=database_id_1) + client_2 = firestore_async.client(database_id=database_id_2) + assert (client_1 is not None) and (client_2 is not None) + assert client_1 is not client_2 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_1._database == 'mock-database-id-1' + assert client_2._database == 'mock-database-id-2' + + def test_database_id_with_multi_db_uses_cache(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id = 'mock-database-id' + client_1 = firestore_async.client(database_id=database_id) + client_2 = firestore_async.client(database_id=database_id) + assert (client_1 is not None) and (client_2 is not None) + assert client_1 is client_2 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_1._database == 'mock-database-id' + assert client_2._database == 'mock-database-id' + + def test_database_id_with_multi_db_uses_cache_default(self): + cred = credentials.Certificate(testutils.resource_filename('service_account.json')) + firebase_admin.initialize_app(cred) + database_id_1 = '' + database_id_2 = '(default)' + client_1 = firestore_async.client(database_id=database_id_1) + client_2 = firestore_async.client(database_id=database_id_2) + client_3 = firestore_async.client() + assert (client_1 is not None) and (client_2 is not None) and (client_3 is not None) + assert client_1 is client_2 + assert client_1 is client_3 + assert client_2 is client_3 + assert client_1.project == 'mock-project-id' + assert client_2.project == 'mock-project-id' + assert client_3.project == 'mock-project-id' + assert client_1._database == '(default)' + assert client_2._database == '(default)' + assert client_3._database == '(default)' + + + def test_geo_point(self): + geo_point = firestore_async.GeoPoint(10, 20) # pylint: disable=no-member + assert geo_point.latitude == 10 + assert geo_point.longitude == 20 + + def test_server_timestamp(self): + assert firestore_async.SERVER_TIMESTAMP is not None # pylint: disable=no-member diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 000000000..0f766767a --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,433 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the firebase_admin.functions module.""" + +from datetime import datetime, timedelta, timezone +import json +import time +import pytest + +import firebase_admin +from firebase_admin import functions +from firebase_admin import _utils +from tests import testutils + + +_DEFAULT_DATA = {'city': 'Seattle'} +_CLOUD_TASKS_URL = 'https://cloudtasks.googleapis.com/v2/' +_DEFAULT_TASK_PATH = \ + 'projects/test-project/locations/us-central1/queues/test-function-name/tasks/test-task-id' +_DEFAULT_REQUEST_URL = \ + _CLOUD_TASKS_URL + 'projects/test-project/locations/us-central1/queues/test-function-name/tasks' +_DEFAULT_TASK_URL = _CLOUD_TASKS_URL + _DEFAULT_TASK_PATH +_DEFAULT_RESPONSE = json.dumps({'name': _DEFAULT_TASK_PATH}) + +class TestTaskQueue: + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'test-project'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def _instrument_functions_service( + self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): + if not app: + app = firebase_admin.get_app() + functions_service = functions._get_functions_service(app) + recorder = [] + functions_service._http_client.session.mount( + mounted_url, + testutils.MockAdapter(payload, status, recorder)) + return functions_service, recorder + + def test_task_queue_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no-project-id') + with pytest.raises(ValueError): + functions.task_queue('test-function-name', app=app) + testutils.run_without_project_id(evaluate) + + @pytest.mark.parametrize('function_name', [ + 'projects/test-project/locations/us-central1/functions/test-function-name', + 'locations/us-central1/functions/test-function-name', + 'test-function-name', + ]) + def test_task_queue_function_name(self, function_name): + queue = functions.task_queue(function_name) + assert queue._resource.resource_id == 'test-function-name' + assert queue._resource.project_id == 'test-project' + assert queue._resource.location_id == 'us-central1' + + def test_task_queue_empty_function_name_error(self): + with pytest.raises(ValueError) as excinfo: + functions.task_queue('') + assert str(excinfo.value) == 'function_name "" must be a non-empty string.' + + def test_task_queue_non_string_function_name_error(self): + with pytest.raises(ValueError) as excinfo: + functions.task_queue(1234) + assert str(excinfo.value) == 'function_name "1234" must be a string.' + + @pytest.mark.parametrize('function_name', [ + '/test', + 'test/', + 'test-project/us-central1/test-function-name', + 'projects/test-project/functions/test-function-name', + 'functions/test-function-name', + ]) + def test_task_queue_invalid_function_name_error(self, function_name): + with pytest.raises(ValueError) as excinfo: + functions.task_queue(function_name) + assert str(excinfo.value) == 'Invalid resource name format.' + + def test_task_queue_extension_id(self): + queue = functions.task_queue("test-function-name", "test-extension-id") + assert queue._resource.resource_id == 'ext-test-extension-id-test-function-name' + assert queue._resource.project_id == 'test-project' + assert queue._resource.location_id == 'us-central1' + + def test_task_queue_empty_extension_id_error(self): + with pytest.raises(ValueError) as excinfo: + functions.task_queue('test-function-name', '') + assert str(excinfo.value) == 'extension_id "" must be a non-empty string.' + + def test_task_queue_non_string_extension_id_error(self): + with pytest.raises(ValueError) as excinfo: + functions.task_queue('test-function-name', 1234) + assert str(excinfo.value) == 'extension_id "1234" must be a string.' + + + def test_task_enqueue(self): + _, recorder = self._instrument_functions_service() + queue = functions.task_queue('test-function-name') + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _DEFAULT_REQUEST_URL + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_with_extension(self): + resource_name = ( + 'projects/test-project/locations/us-central1/queues/' + 'ext-test-extension-id-test-function-name/tasks' + ) + extension_response = json.dumps({'name': resource_name + '/test-task-id'}) + _, recorder = self._instrument_functions_service(payload=extension_response) + queue = functions.task_queue('test-function-name', 'test-extension-id') + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _CLOUD_TASKS_URL + resource_name + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_compute_engine(self): + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce') + _, recorder = self._instrument_functions_service(app) + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _DEFAULT_REQUEST_URL + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-gce-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_with_extension_compute_engine(self): + resource_name = ( + 'projects/test-project/locations/us-central1/queues/' + 'ext-test-extension-id-test-function-name/tasks' + ) + extension_response = json.dumps({'name': resource_name + '/test-task-id'}) + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce-extensions') + _, recorder = self._instrument_functions_service(app, payload=extension_response) + queue = functions.task_queue('test-function-name', 'test-extension-id', app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _CLOUD_TASKS_URL + resource_name + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert 'oidcToken' not in task['httpRequest'] + assert task['httpRequest']['headers'] == { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer mock-compute-engine-token'} + + def test_task_delete(self): + _, recorder = self._instrument_functions_service() + queue = functions.task_queue('test-function-name') + queue.delete('test-task-id') + assert len(recorder) == 1 + assert recorder[0].method == 'DELETE' + assert recorder[0].url == _DEFAULT_TASK_URL + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + + def test_task_enqueue_with_emulator_host(self, monkeypatch): + emulator_host = 'localhost:8124' + emulator_url = f'http://{emulator_host}/' + request_url = emulator_url + _DEFAULT_TASK_PATH.replace('/tasks/test-task-id', '/tasks') + + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', emulator_host) + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') + + expected_task_name = ( + '/projects/test-project/locations/us-central1' + '/queues/test-function-name/tasks/test-task-id' + ) + expected_response = json.dumps({'task': {'name': expected_task_name}}) + _, recorder = self._instrument_functions_service( + app, payload=expected_response, mounted_url=emulator_url) + + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == request_url + assert recorder[0].headers['Content-Type'] == 'application/json' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == { + 'serviceAccountEmail': 'emulated-service-acct@email.com' + } + assert task_id == 'test-task-id' + + def test_task_enqueue_without_emulator_host_error(self, monkeypatch): + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), + {'projectId': 'test-project'}, name='no-emulator-app') + + _, recorder = self._instrument_functions_service(app) + monkeypatch.delenv('CLOUD_TASKS_EMULATOR_HOST', raising=False) + queue = functions.task_queue('test-function-name', app=app) + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA) + assert "Failed to determine service account" in str(excinfo.value) + assert len(recorder) == 0 + + def test_get_emulator_url_invalid_format(self, monkeypatch): + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', 'http://localhost:8124') + app = firebase_admin.initialize_app( + testutils.MockCredential(), {'projectId': 'test-project'}, name='invalid-host-app') + with pytest.raises(ValueError) as excinfo: + functions.task_queue('test-function-name', app=app) + assert 'Invalid CLOUD_TASKS_EMULATOR_HOST' in str(excinfo.value) + +class TestTaskQueueOptions: + + _DEFAULT_TASK_OPTS = {'schedule_delay_seconds': None, 'schedule_time': None, \ + 'dispatch_deadline_seconds': None, 'task_id': None, 'headers': None} + + non_alphanumeric_chars = [ + ',', '.', '?', '!', ':', ';', "'", '"', '(', ')', '[', ']', '{', '}', + '@', '&', '*', '+', '=', '$', '%', '#', '~', '\\', '/', '|', '^', + '\t', '\n', '\r', '\f', '\v', '\0', '\a', '\b', + 'é', 'ç', 'ö', '❤️', '€', '¥', '£', '←', '→', '↑', '↓', 'π', 'Ω', 'ß' + ] + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'test-project'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE): + if not app: + app = firebase_admin.get_app() + functions_service = functions._get_functions_service(app) + recorder = [] + functions_service._http_client.session.mount( + _CLOUD_TASKS_URL, + testutils.MockAdapter(payload, status, recorder)) + return functions_service, recorder + + def test_task_options_delay_seconds(self): + _, recorder = self._instrument_functions_service() + enqueue_time = datetime.now(timezone.utc) + expected_schedule_time = enqueue_time + timedelta(seconds=100) + task_opts_params = { + 'schedule_delay_seconds': 100, + 'schedule_time': None, + 'dispatch_deadline_seconds': 200, + 'task_id': 'test-task-id', + 'headers': {'x-test-header': 'test-header-value'}, + 'uri': 'https://google.com' + } + queue = functions.task_queue('test-function-name') + task_opts = functions.TaskOptions(**task_opts_params) + queue.enqueue(_DEFAULT_DATA, task_opts) + + assert len(recorder) == 1 + task = json.loads(recorder[0].body.decode())['task'] + + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) + delta = abs(task_schedule_time - expected_schedule_time) + assert delta <= timedelta(seconds=1) + + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] + assert task['name'] == _DEFAULT_TASK_PATH + + def test_task_options_utc_time(self): + _, recorder = self._instrument_functions_service() + enqueue_time = datetime.now(timezone.utc) + expected_schedule_time = enqueue_time + timedelta(seconds=100) + task_opts_params = { + 'schedule_delay_seconds': None, + 'schedule_time': expected_schedule_time, + 'dispatch_deadline_seconds': 200, + 'task_id': 'test-task-id', + 'headers': {'x-test-header': 'test-header-value'}, + 'uri': 'http://google.com' + } + queue = functions.task_queue('test-function-name') + task_opts = functions.TaskOptions(**task_opts_params) + queue.enqueue(_DEFAULT_DATA, task_opts) + + assert len(recorder) == 1 + task = json.loads(recorder[0].body.decode())['task'] + + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) + assert task_schedule_time == expected_schedule_time + + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] + assert task['name'] == _DEFAULT_TASK_PATH + + def test_schedule_set_twice_error(self): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions( + schedule_delay_seconds=100, schedule_time=datetime.now(timezone.utc)) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == \ + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.' + + + @pytest.mark.parametrize('schedule_time', [ + time.time(), + str(datetime.now(timezone.utc)), + datetime.now(timezone.utc).isoformat(), + datetime.now(timezone.utc).isoformat() + 'Z', + '', ' ' + ]) + def test_invalid_schedule_time_error(self, schedule_time): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions(schedule_time=schedule_time) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == 'schedule_time should be UTC datetime.' + + + @pytest.mark.parametrize('schedule_delay_seconds', [ + -1, '100', '-1', '', ' ', -1.23, 1.23 + ]) + def test_invalid_schedule_delay_seconds_error(self, schedule_delay_seconds): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions(schedule_delay_seconds=schedule_delay_seconds) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == 'schedule_delay_seconds should be positive int.' + + + @pytest.mark.parametrize('dispatch_deadline_seconds', [ + 14, 1801, -15, -1800, 0, '100', '-1', '', ' ', -1.23, 1.23, + ]) + def test_invalid_dispatch_deadline_seconds_error(self, dispatch_deadline_seconds): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions(dispatch_deadline_seconds=dispatch_deadline_seconds) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == \ + 'dispatch_deadline_seconds should be int in the range of 15s to 1800s (30 mins).' + + + @pytest.mark.parametrize('task_id', [ + '', ' ', 'task/1', 'task.1', 'a'*501, *non_alphanumeric_chars + ]) + def test_invalid_task_id_error(self, task_id): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions(task_id=task_id) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == ( + 'task_id can contain only letters ([A-Za-z]), numbers ([0-9]), ' + 'hyphens (-), or underscores (_). The maximum length is 500 characters.' + ) + + @pytest.mark.parametrize('uri', [ + '', ' ', 'a', 'foo', 'image.jpg', [], {}, True, 'google.com', 'www.google.com' + ]) + def test_invalid_uri_error(self, uri): + _, recorder = self._instrument_functions_service() + opts = functions.TaskOptions(uri=uri) + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA, opts) + assert len(recorder) == 0 + assert str(excinfo.value) == \ + 'uri must be a valid RFC3986 URI string using the https or http schema.' diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 12ba03b48..f1e7f6a64 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -13,90 +13,131 @@ # limitations under the License. """Tests for firebase_admin._http_client.""" +from typing import Dict, Optional, Union import pytest +import httpx +import respx from pytest_localserver import http +from pytest_mock import MockerFixture import requests -from firebase_admin import _http_client +from firebase_admin import _http_client, _utils +from firebase_admin._retry import HttpxRetry, HttpxRetryTransport +from firebase_admin._http_client import ( + HttpxAsyncClient, + GoogleAuthCredentialFlow, + DEFAULT_TIMEOUT_SECONDS +) from tests import testutils _TEST_URL = 'http://firebase.test.url/' +@pytest.fixture +def default_retry_config() -> HttpxRetry: + """Provides a fresh copy of the default retry config instance.""" + return _http_client.DEFAULT_HTTPX_RETRY_CONFIG -def test_http_client_default_session(): - client = _http_client.HttpClient() - assert client.session is not None - assert client.base_url == '' - recorder = _instrument(client, 'body') - resp = client.request('get', _TEST_URL) - assert resp.status_code == 200 - assert resp.text == 'body' - assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == _TEST_URL - -def test_http_client_custom_session(): - session = requests.Session() - client = _http_client.HttpClient(session=session) - assert client.session is session - assert client.base_url == '' - recorder = _instrument(client, 'body') - resp = client.request('get', _TEST_URL) - assert resp.status_code == 200 - assert resp.text == 'body' - assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == _TEST_URL - -def test_base_url(): - client = _http_client.HttpClient(base_url=_TEST_URL) - assert client.session is not None - assert client.base_url == _TEST_URL - recorder = _instrument(client, 'body') - resp = client.request('get', 'foo') - assert resp.status_code == 200 - assert resp.text == 'body' - assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == _TEST_URL + 'foo' - -def test_credential(): - client = _http_client.HttpClient( - credential=testutils.MockGoogleCredential()) - assert client.session is not None - recorder = _instrument(client, 'body') - resp = client.request('get', _TEST_URL) - assert resp.status_code == 200 - assert resp.text == 'body' - assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == _TEST_URL - assert recorder[0].headers['Authorization'] == 'Bearer mock-token' - -@pytest.mark.parametrize('options, timeout', [ - ({}, _http_client.DEFAULT_TIMEOUT_SECONDS), - ({'timeout': 7}, 7), - ({'timeout': 0}, 0), - ({'timeout': None}, None), -]) -def test_timeout(options, timeout): - client = _http_client.HttpClient(**options) - assert client.timeout == timeout - recorder = _instrument(client, 'body') - client.request('get', _TEST_URL) - assert len(recorder) == 1 - if timeout is None: - assert recorder[0]._extra_kwargs['timeout'] is None - else: - assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001) - - -def _instrument(client, payload, status=200): - recorder = [] - adapter = testutils.MockAdapter(payload, status, recorder) - client.session.mount(_TEST_URL, adapter) - return recorder +class TestHttpClient: + def test_http_client_default_session(self): + client = _http_client.HttpClient() + assert client.session is not None + assert client.base_url == '' + recorder = self._instrument(client, 'body') + resp = client.request('get', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + + def test_http_client_custom_session(self): + session = requests.Session() + client = _http_client.HttpClient(session=session) + assert client.session is session + assert client.base_url == '' + recorder = self._instrument(client, 'body') + resp = client.request('get', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + + def test_base_url(self): + client = _http_client.HttpClient(base_url=_TEST_URL) + assert client.session is not None + assert client.base_url == _TEST_URL + recorder = self._instrument(client, 'body') + resp = client.request('get', 'foo') + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + 'foo' + + def test_metrics_headers(self): + client = _http_client.HttpClient() + assert client.session is not None + recorder = self._instrument(client, 'body') + resp = client.request('get', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + assert recorder[0].headers['x-goog-api-client'] == _utils.get_metrics_header() + + def test_metrics_headers_with_credentials(self): + client = _http_client.HttpClient( + credential=testutils.MockGoogleCredential()) + assert client.session is not None + recorder = self._instrument(client, 'body') + resp = client.request('get', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + + def test_credential(self): + client = _http_client.HttpClient( + credential=testutils.MockGoogleCredential()) + assert client.session is not None + recorder = self._instrument(client, 'body') + resp = client.request('get', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert len(recorder) == 1 + assert recorder[0].method == 'GET' + assert recorder[0].url == _TEST_URL + assert recorder[0].headers['Authorization'] == 'Bearer mock-token' + + @pytest.mark.parametrize('options, timeout', [ + ({}, _http_client.DEFAULT_TIMEOUT_SECONDS), + ({'timeout': 7}, 7), + ({'timeout': 0}, 0), + ({'timeout': None}, None), + ]) + def test_timeout(self, options, timeout): + client = _http_client.HttpClient(**options) + assert client.timeout == timeout + recorder = self._instrument(client, 'body') + client.request('get', _TEST_URL) + assert len(recorder) == 1 + if timeout is None: + assert recorder[0]._extra_kwargs['timeout'] is None + else: + assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001) + + + def _instrument(self, client, payload, status=200): + recorder = [] + adapter = testutils.MockAdapter(payload, status, recorder) + client.session.mount(_TEST_URL, adapter) + return recorder class TestHttpRetry: @@ -157,3 +198,473 @@ def test_no_retry_on_404(self): client.request('get', '/') assert excinfo.value.response.status_code == 404 assert len(self.httpserver.requests) == 1 + +class TestHttpxAsyncClient: + def test_init_default(self, mocker: MockerFixture, default_retry_config: HttpxRetry): + """Test client initialization with default settings (no credentials).""" + + # Mock httpx.AsyncClient and HttpxRetryTransport init to check args passed to them + mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + mock_transport_init = mocker.patch( + 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None + ) + + client = HttpxAsyncClient() + + assert client.base_url == '' + assert client.timeout == DEFAULT_TIMEOUT_SECONDS + assert client._headers == _http_client.METRICS_HEADERS + assert client._retry_config == default_retry_config + + # Check httpx.AsyncClient call args + _, init_kwargs = mock_async_client_init.call_args + assert init_kwargs.get('http2') is True + assert init_kwargs.get('timeout') == DEFAULT_TIMEOUT_SECONDS + assert init_kwargs.get('headers') == _http_client.METRICS_HEADERS + assert init_kwargs.get('auth') is None + assert 'mounts' in init_kwargs + assert 'http://' in init_kwargs['mounts'] + assert 'https://' in init_kwargs['mounts'] + assert isinstance(init_kwargs['mounts']['http://'], HttpxRetryTransport) + assert isinstance(init_kwargs['mounts']['https://'], HttpxRetryTransport) + + # Check that HttpxRetryTransport was initialized with the default retry config + assert mock_transport_init.call_count >= 1 + _, transport_call_kwargs = mock_transport_init.call_args_list[0] + assert transport_call_kwargs.get('retry') == default_retry_config + assert transport_call_kwargs.get('http2') is True + + def test_init_with_credentials(self, mocker: MockerFixture, default_retry_config: HttpxRetry): + """Test client initialization with credentials.""" + + # Mock GoogleAuthCredentialFlow, httpx.AsyncClient and HttpxRetryTransport init to + # check args passed to them + mock_auth_flow_init = mocker.patch( + 'firebase_admin._http_client.GoogleAuthCredentialFlow.__init__', return_value=None + ) + mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + mock_transport_init = mocker.patch( + 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None + ) + + mock_credential = testutils.MockGoogleCredential() + client = HttpxAsyncClient(credential=mock_credential) + + assert client.base_url == '' + assert client.timeout == DEFAULT_TIMEOUT_SECONDS + assert client._headers == _http_client.METRICS_HEADERS + assert client._retry_config == default_retry_config + + # Verify GoogleAuthCredentialFlow was initialized with the credential + mock_auth_flow_init.assert_called_once_with(mock_credential) + + # Check httpx.AsyncClient call args + _, init_kwargs = mock_async_client_init.call_args + assert init_kwargs.get('http2') is True + assert init_kwargs.get('timeout') == DEFAULT_TIMEOUT_SECONDS + assert init_kwargs.get('headers') == _http_client.METRICS_HEADERS + assert isinstance(init_kwargs.get('auth'), GoogleAuthCredentialFlow) + assert 'mounts' in init_kwargs + assert 'http://' in init_kwargs['mounts'] + assert 'https://' in init_kwargs['mounts'] + assert isinstance(init_kwargs['mounts']['http://'], HttpxRetryTransport) + assert isinstance(init_kwargs['mounts']['https://'], HttpxRetryTransport) + + # Check that HttpxRetryTransport was initialized with the default retry config + assert mock_transport_init.call_count >= 1 + _, transport_call_kwargs = mock_transport_init.call_args_list[0] + assert transport_call_kwargs.get('retry') == default_retry_config + assert transport_call_kwargs.get('http2') is True + + def test_init_with_custom_settings(self, mocker: MockerFixture): + """Test client initialization with custom settings.""" + + # Mock httpx.AsyncClient and HttpxRetryTransport init to check args passed to them + mock_auth_flow_init = mocker.patch( + 'firebase_admin._http_client.GoogleAuthCredentialFlow.__init__', return_value=None + ) + mock_async_client_init = mocker.patch('httpx.AsyncClient.__init__', return_value=None) + mock_transport_init = mocker.patch( + 'firebase_admin._retry.HttpxRetryTransport.__init__', return_value=None + ) + + mock_credential = testutils.MockGoogleCredential() + headers = {'X-Custom': 'Test'} + custom_retry = HttpxRetry(max_retries=1, status_forcelist=[429], backoff_factor=0) + timeout = 60 + http2 = False + + expected_headers = {**headers, **_http_client.METRICS_HEADERS} + + client = HttpxAsyncClient( + credential=mock_credential, base_url=_TEST_URL, headers=headers, + retry_config=custom_retry, timeout=timeout, http2=http2) + + assert client.base_url == _TEST_URL + assert client._headers == expected_headers + assert client._retry_config == custom_retry + assert client.timeout == timeout + + # Verify GoogleAuthCredentialFlow was initialized with the credential + mock_auth_flow_init.assert_called_once_with(mock_credential) + # Verify original headers are not mutated + assert headers == {'X-Custom': 'Test'} + + # Check httpx.AsyncClient call args + _, init_kwargs = mock_async_client_init.call_args + assert init_kwargs.get('http2') is False + assert init_kwargs.get('timeout') == timeout + assert init_kwargs.get('headers') == expected_headers + assert isinstance(init_kwargs.get('auth'), GoogleAuthCredentialFlow) + assert 'mounts' in init_kwargs + assert 'http://' in init_kwargs['mounts'] + assert 'https://' in init_kwargs['mounts'] + assert isinstance(init_kwargs['mounts']['http://'], HttpxRetryTransport) + assert isinstance(init_kwargs['mounts']['https://'], HttpxRetryTransport) + + # Check that HttpxRetryTransport was initialized with the default retry config + assert mock_transport_init.call_count >= 1 + _, transport_call_kwargs = mock_transport_init.call_args_list[0] + assert transport_call_kwargs.get('retry') == custom_retry + assert transport_call_kwargs.get('http2') is False + + + @respx.mock + @pytest.mark.asyncio + async def test_request(self): + """Test client request.""" + + client = HttpxAsyncClient() + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + resp = await client.request('post', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + @respx.mock + @pytest.mark.asyncio + async def test_request_raise_for_status(self): + """Test client request raise for status error.""" + + client = HttpxAsyncClient() + + responses = [ + respx.MockResponse(404, http_version='HTTP/2', content='Status error'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + resp = await client.request('post', _TEST_URL) + resp = exc_info.value.response + assert resp.status_code == 404 + assert resp.text == 'Status error' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + + @respx.mock + @pytest.mark.asyncio + async def test_request_with_base_url(self): + """Test client request with base_url.""" + + client = HttpxAsyncClient(base_url=_TEST_URL) + + url_extension = 'post/123' + responses = [ + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL + url_extension).mock(side_effect=responses) + + resp = await client.request('POST', url_extension) + assert resp.status_code == 200 + assert resp.text == 'body' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + url_extension + self.check_headers(request.headers, has_auth=False) + + @respx.mock + @pytest.mark.asyncio + async def test_request_with_timeout(self): + """Test client request with timeout.""" + + timeout = 60 + client = HttpxAsyncClient(timeout=timeout) + responses = [ + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + resp = await client.request('POST', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + @respx.mock + @pytest.mark.asyncio + async def test_request_with_credential(self): + """Test client request with credentials.""" + + mock_credential = testutils.MockGoogleCredential() + client = HttpxAsyncClient(credential=mock_credential) + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', content='test'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + resp = await client.request('post', _TEST_URL) + + assert resp.status_code == 200 + assert resp.text == 'test' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers) + + @respx.mock + @pytest.mark.asyncio + async def test_request_with_headers(self): + """Test client request with credentials.""" + + mock_credential = testutils.MockGoogleCredential() + headers = httpx.Headers({'X-Custom': 'Test'}) + client = HttpxAsyncClient(credential=mock_credential, headers=headers) + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + resp = await client.request('post', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, expected_headers=headers) + + + @respx.mock + @pytest.mark.asyncio + async def test_response_get_headers(self): + """Test the headers() helper method.""" + + client = HttpxAsyncClient() + expected_headers = {'X-Custom': 'Test'} + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', headers=expected_headers), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + headers = await client.headers('post', _TEST_URL) + + self.check_headers( + headers, expected_headers=expected_headers, has_auth=False, has_metrics=False + ) + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + @respx.mock + @pytest.mark.asyncio + async def test_response_get_body_and_response(self): + """Test the body_and_response() helper method.""" + + client = HttpxAsyncClient() + expected_body = {'key': 'value'} + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json=expected_body), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + body, resp = await client.body_and_response('post', _TEST_URL) + + assert resp.status_code == 200 + assert body == expected_body + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + + @respx.mock + @pytest.mark.asyncio + async def test_response_get_body(self): + """Test the body() helper method.""" + + client = HttpxAsyncClient() + expected_body = {'key': 'value'} + + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json=expected_body), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + body = await client.body('post', _TEST_URL) + + assert body == expected_body + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + @respx.mock + @pytest.mark.asyncio + async def test_response_get_headers_and_body(self): + """Test the headers_and_body() helper method.""" + + client = HttpxAsyncClient() + expected_headers = {'X-Custom': 'Test'} + expected_body = {'key': 'value'} + + responses = [ + respx.MockResponse( + 200, http_version='HTTP/2', json=expected_body, headers=expected_headers), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + headers, body = await client.headers_and_body('post', _TEST_URL) + + assert body == expected_body + self.check_headers( + headers, expected_headers=expected_headers, has_auth=False, has_metrics=False + ) + assert route.call_count == 1 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + self.check_headers(request.headers, has_auth=False) + + @pytest.mark.asyncio + async def test_aclose(self): + """Test that aclose calls the underlying client's aclose.""" + + client = HttpxAsyncClient() + assert client._async_client.is_closed is False + await client.aclose() + assert client._async_client.is_closed is True + + + def check_headers( + self, + headers: Union[httpx.Headers, Dict[str, str]], + expected_headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None, + has_auth: bool = True, + has_metrics: bool = True + ): + if expected_headers: + for header_key in expected_headers.keys(): + assert header_key in headers + assert headers.get(header_key) == expected_headers.get(header_key) + + if has_auth: + assert 'Authorization' in headers + assert headers.get('Authorization') == 'Bearer mock-token' + + if has_metrics: + for header_key in _http_client.METRICS_HEADERS: + assert header_key in headers + expected_metrics_header = _http_client.METRICS_HEADERS.get(header_key, '') + if has_auth: + expected_metrics_header += ' mock-cred-metric-tag' + assert headers.get(header_key) == expected_metrics_header + + +class TestGoogleAuthCredentialFlow: + + @respx.mock + @pytest.mark.asyncio + async def test_auth_headers_retry(self): + """Test invalid credential retry.""" + + mock_credential = testutils.MockGoogleCredential() + client = HttpxAsyncClient(credential=mock_credential) + + responses = [ + respx.MockResponse(401, http_version='HTTP/2', content='Auth error'), + respx.MockResponse(401, http_version='HTTP/2', content='Auth error'), + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + resp = await client.request('post', _TEST_URL) + assert resp.status_code == 200 + assert resp.text == 'body' + assert route.call_count == 3 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + headers = request.headers + assert 'Authorization' in headers + assert headers.get('Authorization') == 'Bearer mock-token' + + @respx.mock + @pytest.mark.asyncio + async def test_auth_headers_retry_exhausted(self, mocker: MockerFixture): + """Test invalid credential retry exhausted.""" + + mock_credential = testutils.MockGoogleCredential() + mock_credential_patch = mocker.spy(mock_credential, 'refresh') + client = HttpxAsyncClient(credential=mock_credential) + + responses = [ + respx.MockResponse(401, http_version='HTTP/2', content='Auth error'), + respx.MockResponse(401, http_version='HTTP/2', content='Auth error'), + respx.MockResponse(401, http_version='HTTP/2', content='Auth error'), + # Should stop after previous response + respx.MockResponse(200, http_version='HTTP/2', content='body'), + ] + route = respx.request('POST', _TEST_URL).mock(side_effect=responses) + + with pytest.raises(httpx.HTTPStatusError) as exc_info: + resp = await client.request('post', _TEST_URL) + resp = exc_info.value.response + assert resp.status_code == 401 + assert resp.text == 'Auth error' + assert route.call_count == 3 + + assert mock_credential_patch.call_count == 3 + + request = route.calls.last.request + assert request.method == 'POST' + assert request.url == _TEST_URL + headers = request.headers + assert 'Authorization' in headers + assert headers.get('Authorization') == 'Bearer mock-token' diff --git a/tests/test_instance_id.py b/tests/test_instance_id.py index 08b0fe6db..2b0e21079 100644 --- a/tests/test_instance_id.py +++ b/tests/test_instance_id.py @@ -20,6 +20,7 @@ from firebase_admin import exceptions from firebase_admin import instance_id from firebase_admin import _http_client +from firebase_admin import _utils from tests import testutils @@ -64,8 +65,14 @@ def _instrument_iid_service(self, app, status=200, payload='True'): testutils.MockAdapter(payload, status, recorder)) return iid_service, recorder + def _assert_request(self, request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + def _get_url(self, project_id, iid): - return instance_id._IID_SERVICE_URL + 'project/{0}/instanceId/{1}'.format(project_id, iid) + return instance_id._IID_SERVICE_URL + f'project/{project_id}/instanceId/{iid}' def test_no_project_id(self): def evaluate(): @@ -86,8 +93,8 @@ def test_delete_instance_id(self): _, recorder = self._instrument_iid_service(app) instance_id.delete_instance_id('test_iid') assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid') + self._assert_request( + recorder[0], 'DELETE', self._get_url('explicit-project-id', 'test_iid')) def test_delete_instance_id_with_explicit_app(self): cred = testutils.MockCredential() @@ -95,8 +102,8 @@ def test_delete_instance_id_with_explicit_app(self): _, recorder = self._instrument_iid_service(app) instance_id.delete_instance_id('test_iid', app) assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid') + self._assert_request( + recorder[0], 'DELETE', self._get_url('explicit-project-id', 'test_iid')) @pytest.mark.parametrize('status', http_errors.keys()) def test_delete_instance_id_error(self, status): @@ -114,8 +121,8 @@ def test_delete_instance_id_error(self, status): else: # 401 responses are automatically retried by google-auth assert len(recorder) == 3 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid') + self._assert_request( + recorder[0], 'DELETE', self._get_url('explicit-project-id', 'test_iid')) def test_delete_instance_id_unexpected_error(self): cred = testutils.MockCredential() @@ -124,15 +131,14 @@ def test_delete_instance_id_unexpected_error(self): with pytest.raises(exceptions.UnknownError) as excinfo: instance_id.delete_instance_id('test_iid') url = self._get_url('explicit-project-id', 'test_iid') - message = 'Instance ID "test_iid": 501 Server Error: None for url: {0}'.format(url) + message = f'Instance ID "test_iid": 501 Server Error: None for url: {url}' assert str(excinfo.value) == message assert excinfo.value.cause is not None assert excinfo.value.http_response is not None assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == url + self._assert_request(recorder[0], 'DELETE', url) - @pytest.mark.parametrize('iid', [None, '', 0, 1, True, False, list(), dict(), tuple()]) + @pytest.mark.parametrize('iid', [None, '', 0, 1, True, False, [], {}, tuple()]) def test_invalid_instance_id(self, iid): cred = testutils.MockCredential() app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 6333aad46..b30790f14 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -14,25 +14,28 @@ """Test cases for the firebase_admin.messaging module.""" import datetime +from itertools import chain, repeat import json import numbers +import httpx +import respx -from googleapiclient import http -from googleapiclient import _helpers import pytest import firebase_admin from firebase_admin import exceptions from firebase_admin import messaging from firebase_admin import _http_client +from firebase_admin import _utils from tests import testutils -NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] -NON_DICT_ARGS = ['', list(), tuple(), True, False, 1, 0, {1: 'foo'}, {'foo': 1}] -NON_OBJECT_ARGS = [list(), tuple(), dict(), 'foo', 0, 1, True, False] -NON_LIST_ARGS = ['', tuple(), dict(), True, False, 1, 0, [1], ['foo', 1]] -NON_UINT_ARGS = ['1.23s', list(), tuple(), dict(), -1.23] +NON_STRING_ARGS = [[], tuple(), {}, True, False, 1, 0] +NON_DICT_ARGS = ['', [], tuple(), True, False, 1, 0, {1: 'foo'}, {'foo': 1}] +NON_OBJECT_ARGS = [[], tuple(), {}, 'foo', 0, 1, True, False] +NON_LIST_ARGS = ['', tuple(), {}, True, False, 1, 0, [1], ['foo', 1]] +NON_UINT_ARGS = ['1.23s', [], tuple(), {}, -1.23] +NON_BOOL_ARGS = ['', [], tuple(), {}, 1, 0, [1], ['foo', 1], {1: 'foo'}, {'foo': 1}] HTTP_ERROR_CODES = { 400: exceptions.InvalidArgumentError, 403: exceptions.PermissionDeniedError, @@ -249,7 +252,8 @@ def test_fcm_options(self): topic='topic', fcm_options=messaging.FCMOptions('message-label'), android=messaging.AndroidConfig( - fcm_options=messaging.AndroidFCMOptions('android-label')), + fcm_options=messaging.AndroidFCMOptions('android-label'), + direct_boot_ok=False), apns=messaging.APNSConfig(fcm_options= messaging.APNSFCMOptions( analytics_label='apns-label', @@ -259,7 +263,8 @@ def test_fcm_options(self): { 'topic': 'topic', 'fcm_options': {'analytics_label': 'message-label'}, - 'android': {'fcm_options': {'analytics_label': 'android-label'}}, + 'android': {'fcm_options': {'analytics_label': 'android-label'}, + 'direct_boot_ok': False}, 'apns': {'fcm_options': {'analytics_label': 'apns-label', 'image': 'https://images.unsplash.com/photo-14944386399' '46-1ebd1d20bf85?fit=crop&w=900&q=60'}}, @@ -317,6 +322,32 @@ def test_invalid_data(self, data): check_encoding(messaging.Message( topic='topic', android=messaging.AndroidConfig(data=data))) + @pytest.mark.parametrize('data', NON_STRING_ARGS) + def test_invalid_analytics_label(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig( + fcm_options=messaging.AndroidFCMOptions(analytics_label=data)))) + + @pytest.mark.parametrize('data', NON_BOOL_ARGS) + def test_invalid_direct_boot_ok(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig(direct_boot_ok=data))) + + @pytest.mark.parametrize('data', NON_BOOL_ARGS) + def test_invalid_bandwidth_constrained_ok(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig(bandwidth_constrained_ok=data))) + + @pytest.mark.parametrize('data', NON_BOOL_ARGS) + def test_invalid_restricted_satellite_ok(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig(restricted_satellite_ok=data))) + + def test_android_config(self): msg = messaging.Message( topic='topic', @@ -326,7 +357,10 @@ def test_android_config(self): priority='high', ttl=123, data={'k1': 'v1', 'k2': 'v2'}, - fcm_options=messaging.AndroidFCMOptions('analytics_label_v1') + fcm_options=messaging.AndroidFCMOptions('analytics_label_v1'), + direct_boot_ok=True, + bandwidth_constrained_ok=True, + restricted_satellite_ok=True, ) ) expected = { @@ -343,6 +377,9 @@ def test_android_config(self): 'fcm_options': { 'analytics_label': 'analytics_label_v1', }, + 'direct_boot_ok': True, + 'bandwidth_constrained_ok': True, + 'restricted_satellite_ok': True, }, } check_encoding(msg, expected) @@ -480,7 +517,7 @@ def test_invalid_channel_id(self, data): excinfo = self._check_notification(notification) assert str(excinfo.value) == 'AndroidNotification.channel_id must be a string.' - @pytest.mark.parametrize('timestamp', [100, '', 'foo', {}, [], list(), dict()]) + @pytest.mark.parametrize('timestamp', [100, '', 'foo', {}, []]) def test_invalid_event_timestamp(self, timestamp): notification = messaging.AndroidNotification(event_timestamp=timestamp) excinfo = self._check_notification(notification) @@ -515,6 +552,20 @@ def test_invalid_visibility(self, visibility): expected = 'AndroidNotification.visibility must be a non-empty string.' assert str(excinfo.value) == expected + @pytest.mark.parametrize('proxy', NON_STRING_ARGS + ['foo']) + def test_invalid_proxy(self, proxy): + notification = messaging.AndroidNotification(proxy=proxy) + excinfo = self._check_notification(notification) + if isinstance(proxy, str): + if not proxy: + expected = 'AndroidNotification.proxy must be a non-empty string.' + else: + expected = ('AndroidNotification.proxy must be "allow", "deny" or' + ' "if_priority_lowered".') + else: + expected = 'AndroidNotification.proxy must be a non-empty string.' + assert str(excinfo.value) == expected + @pytest.mark.parametrize('vibrate_timings', ['', 1, True, 'msec', ['500', 500], [0, 'abc']]) def test_invalid_vibrate_timings_millis(self, vibrate_timings): notification = messaging.AndroidNotification(vibrate_timings_millis=vibrate_timings) @@ -533,7 +584,7 @@ def test_negative_vibrate_timings_millis(self): expected = 'AndroidNotification.vibrate_timings_millis must not be negative.' assert str(excinfo.value) == expected - @pytest.mark.parametrize('notification_count', ['', 'foo', list(), tuple(), dict()]) + @pytest.mark.parametrize('notification_count', ['', 'foo', [], tuple(), {}]) def test_invalid_notification_count(self, notification_count): notification = messaging.AndroidNotification(notification_count=notification_count) excinfo = self._check_notification(notification) @@ -560,6 +611,7 @@ def test_android_notification(self): light_off_duration_millis=300, ), default_light_settings=False, visibility='public', notification_count=1, + proxy='if_priority_lowered', ) ) ) @@ -600,6 +652,7 @@ def test_android_notification(self): 'default_light_settings': False, 'visibility': 'PUBLIC', 'notification_count': 1, + 'proxy': 'IF_PRIORITY_LOWERED' }, }, } @@ -902,19 +955,19 @@ def test_invalid_tag(self, data): excinfo = self._check_notification(notification) assert str(excinfo.value) == 'WebpushNotification.tag must be a string.' - @pytest.mark.parametrize('data', ['', 'foo', list(), tuple(), dict()]) + @pytest.mark.parametrize('data', ['', 'foo', [], tuple(), {}]) def test_invalid_timestamp(self, data): notification = messaging.WebpushNotification(timestamp_millis=data) excinfo = self._check_notification(notification) assert str(excinfo.value) == 'WebpushNotification.timestamp_millis must be a number.' - @pytest.mark.parametrize('data', ['', list(), tuple(), True, False, 1, 0]) + @pytest.mark.parametrize('data', ['', [], tuple(), True, False, 1, 0]) def test_invalid_custom_data(self, data): notification = messaging.WebpushNotification(custom_data=data) excinfo = self._check_notification(notification) assert str(excinfo.value) == 'WebpushNotification.custom_data must be a dict.' - @pytest.mark.parametrize('data', ['', dict(), tuple(), True, False, 1, 0, [1, 2]]) + @pytest.mark.parametrize('data', ['', {}, tuple(), True, False, 1, 0, [1, 2]]) def test_invalid_actions(self, data): notification = messaging.WebpushNotification(actions=data) excinfo = self._check_notification(notification) @@ -1058,7 +1111,8 @@ def test_apns_config(self): topic='topic', apns=messaging.APNSConfig( headers={'h1': 'v1', 'h2': 'v2'}, - fcm_options=messaging.APNSFCMOptions('analytics_label_v1') + fcm_options=messaging.APNSFCMOptions('analytics_label_v1'), + live_activity_token='test_token_string' ), ) expected = { @@ -1071,6 +1125,7 @@ def test_apns_config(self): 'fcm_options': { 'analytics_label': 'analytics_label_v1', }, + 'live_activity_token': 'test_token_string', }, } check_encoding(msg, expected) @@ -1133,7 +1188,7 @@ def test_invalid_alert(self, data): expected = 'Aps.alert must be a string or an instance of ApsAlert class.' assert str(excinfo.value) == expected - @pytest.mark.parametrize('data', [list(), tuple(), dict(), 'foo']) + @pytest.mark.parametrize('data', [[], tuple(), {}, 'foo']) def test_invalid_badge(self, data): aps = messaging.Aps(badge=data) with pytest.raises(ValueError) as excinfo: @@ -1165,7 +1220,7 @@ def test_invalid_thread_id(self, data): expected = 'Aps.thread_id must be a string.' assert str(excinfo.value) == expected - @pytest.mark.parametrize('data', ['', list(), tuple(), True, False, 1, 0, ]) + @pytest.mark.parametrize('data', ['', [], tuple(), True, False, 1, 0, ]) def test_invalid_custom_data_dict(self, data): if isinstance(data, dict): return @@ -1270,7 +1325,7 @@ def test_invalid_name(self, data): expected = 'CriticalSound.name must be a non-empty string.' assert str(excinfo.value) == expected - @pytest.mark.parametrize('data', [list(), tuple(), dict(), 'foo']) + @pytest.mark.parametrize('data', [[], tuple(), {}, 'foo']) def test_invalid_volume(self, data): sound = messaging.CriticalSound(name='default', volume=data) excinfo = self._check_sound(sound) @@ -1567,7 +1622,7 @@ def test_aps_alert_custom_data_override(self): class TestTimeout: - def teardown(self): + def teardown_method(self): testutils.cleanup_apps() def _instrument_service(self, url, response): @@ -1620,7 +1675,7 @@ def test_topic_management_custom_timeout(self, options, timeout): class TestSend: _DEFAULT_RESPONSE = json.dumps({'name': 'message-id'}) - _CLIENT_VERSION = 'fire-admin-python/{0}'.format(firebase_admin.__version__) + _CLIENT_VERSION = f'fire-admin-python/{firebase_admin.__version__}' @classmethod def setup_class(cls): @@ -1641,6 +1696,19 @@ def _instrument_messaging_service(self, app=None, status=200, payload=_DEFAULT_R testutils.MockAdapter(payload, status, recorder)) return fcm_service, recorder + + def _assert_request(self, request, expected_method, expected_url, expected_body=None): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['X-GOOG-API-FORMAT-VERSION'] == '2' + assert request.headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + if expected_body is None: + assert request.body is None + else: + assert json.loads(request.body.decode()) == expected_body + def _get_url(self, project_id): return messaging._MessagingService.FCM_URL.format(project_id) @@ -1663,15 +1731,11 @@ def test_send_dry_run(self): msg_id = messaging.send(msg, dry_run=True) assert msg_id == 'message-id' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') - assert recorder[0].headers['X-GOOG-API-FORMAT-VERSION'] == '2' - assert recorder[0].headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION body = { 'message': messaging._MessagingService.encode_message(msg), 'validate_only': True, } - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) def test_send(self): _, recorder = self._instrument_messaging_service() @@ -1679,12 +1743,8 @@ def test_send(self): msg_id = messaging.send(msg) assert msg_id == 'message-id' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') - assert recorder[0].headers['X-GOOG-API-FORMAT-VERSION'] == '2' - assert recorder[0].headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION body = {'message': messaging._MessagingService.encode_message(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) @pytest.mark.parametrize('status,exc_type', HTTP_ERROR_CODES.items()) def test_send_error(self, status, exc_type): @@ -1692,15 +1752,11 @@ def test_send_error(self, status, exc_type): msg = messaging.Message(topic='foo') with pytest.raises(exc_type) as excinfo: messaging.send(msg) - expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) + expected = f'Unexpected HTTP response with status: {status}; body: {{}}' check_exception(excinfo.value, expected, status) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') - assert recorder[0].headers['X-GOOG-API-FORMAT-VERSION'] == '2' - assert recorder[0].headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_detailed_error(self, status): @@ -1716,10 +1772,8 @@ def test_send_detailed_error(self, status): messaging.send(msg) check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_canonical_error_code(self, status): @@ -1735,10 +1789,8 @@ def test_send_canonical_error_code(self, status): messaging.send(msg) check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) @pytest.mark.parametrize('fcm_error_code, exc_type', FCM_ERROR_CODES.items()) @@ -1761,10 +1813,8 @@ def test_send_fcm_error_code(self, status, fcm_error_code, exc_type): messaging.send(msg) check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_unknown_fcm_error_code(self, status): @@ -1786,14 +1836,11 @@ def test_send_unknown_fcm_error_code(self, status): messaging.send(msg) check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} - assert json.loads(recorder[0].body.decode()) == body + self._assert_request(recorder[0], 'POST', self._get_url('explicit-project-id'), body) -class TestBatch: - +class TestSendEach(): @classmethod def setup_class(cls): cred = testutils.MockCredential() @@ -1803,44 +1850,27 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def _instrument_batch_messaging_service(self, app=None, status=200, payload=''): + def _instrument_messaging_service(self, response_dict, app=None): if not app: app = firebase_admin.get_app() fcm_service = messaging._get_messaging_service(app) - if status == 200: - content_type = 'multipart/mixed; boundary=boundary' - else: - content_type = 'application/json' - fcm_service._transport = http.HttpMockSequence([ - ({'status': str(status), 'content-type': content_type}, payload), - ]) - return fcm_service - - def _batch_payload(self, payloads): - # payloads should be a list of (status_code, content) tuples - payload = '' - _playload_format = """--boundary\r\nContent-Type: application/http\r\n\ -Content-ID: \r\n\r\nHTTP/1.1 {} Success\r\n\ -Content-Type: application/json; charset=UTF-8\r\n\r\n{}\r\n\r\n""" - for (index, (status_code, content)) in enumerate(payloads): - payload += _playload_format.format(str(index + 1), str(status_code), content) - payload += '--boundary--' - return payload - - -class TestSendAll(TestBatch): + recorder = [] + fcm_service._client.session.mount( + 'https://fcm.googleapis.com', + testutils.MockRequestBasedMultiRequestAdapter(response_dict, recorder)) + return fcm_service, recorder def test_no_project_id(self): def evaluate(): app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') with pytest.raises(ValueError): - messaging.send_all([messaging.Message(topic='foo')], app=app) + messaging.send_each([messaging.Message(topic='foo')], app=app) testutils.run_without_project_id(evaluate) @pytest.mark.parametrize('msg', NON_LIST_ARGS) - def test_invalid_send_all(self, msg): + def test_invalid_send_each(self, msg): with pytest.raises(ValueError) as excinfo: - messaging.send_all(msg) + messaging.send_each(msg) if isinstance(msg, list): expected = 'Message must be an instance of messaging.Message class.' assert str(excinfo.value) == expected @@ -1851,39 +1881,217 @@ def test_invalid_send_all(self, msg): def test_invalid_over_500(self): msg = messaging.Message(topic='foo') with pytest.raises(ValueError) as excinfo: - messaging.send_all([msg for _ in range(0, 501)]) + messaging.send_each([msg for _ in range(0, 501)]) expected = 'messages must not contain more than 500 elements.' assert str(excinfo.value) == expected - def test_send_all(self): - payload = json.dumps({'name': 'message-id'}) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, payload), (200, payload)])) - msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg, msg], dry_run=True) + def test_send_each(self): + payload1 = json.dumps({'name': 'message-id1'}) + payload2 = json.dumps({'name': 'message-id2'}) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, payload1], 'foo2': [200, payload2]}) + msg1 = messaging.Message(topic='foo1') + msg2 = messaging.Message(topic='foo2') + batch_response = messaging.send_each([msg1, msg2], dry_run=True) assert batch_response.success_count == 2 assert batch_response.failure_count == 0 assert len(batch_response.responses) == 2 - assert [r.message_id for r in batch_response.responses] == ['message-id', 'message-id'] - assert all([r.success for r in batch_response.responses]) - assert not any([r.exception for r in batch_response.responses]) - - def test_send_all_with_positional_param_enforcement(self): - payload = json.dumps({'name': 'message-id'}) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, payload), (200, payload)])) - msg = messaging.Message(topic='foo') + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_async(self): + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id2'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id3'}), + ] + msg1 = messaging.Message(topic='foo1') + msg2 = messaging.Message(topic='foo2') + msg3 = messaging.Message(topic='foo3') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + + batch_response = await messaging.send_each_async([msg1, msg2, msg3], dry_run=True) + + assert batch_response.success_count == 3 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 3 + assert [r.message_id for r in batch_response.responses] \ + == ['message-id1', 'message-id2', 'message-id3'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + assert route.call_count == 3 + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_async_error_401_fail_auth_retry(self): + payload = json.dumps({ + 'error': { + 'status': 'UNAUTHENTICATED', + 'message': 'test unauthenticated error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'SOME_UNKNOWN_CODE', + }, + ], + } + }) + + responses = repeat(respx.MockResponse(401, http_version='HTTP/2', content=payload)) + + msg1 = messaging.Message(topic='foo1') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + batch_response = await messaging.send_each_async([msg1], dry_run=True) + + assert route.call_count == 3 + assert batch_response.success_count == 0 + assert batch_response.failure_count == 1 + assert len(batch_response.responses) == 1 + exception = batch_response.responses[0].exception + assert isinstance(exception, exceptions.UnauthenticatedError) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_async_error_401_pass_on_auth_retry(self): + payload = json.dumps({ + 'error': { + 'status': 'UNAUTHENTICATED', + 'message': 'test unauthenticated error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'SOME_UNKNOWN_CODE', + }, + ], + } + }) + responses = [ + respx.MockResponse(401, http_version='HTTP/2', content=payload), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + ] + + msg1 = messaging.Message(topic='foo1') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + batch_response = await messaging.send_each_async([msg1], dry_run=True) + + assert route.call_count == 2 + assert batch_response.success_count == 1 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 1 + assert [r.message_id for r in batch_response.responses] == ['message-id1'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_async_error_500_fail_retry_config(self): + payload = json.dumps({ + 'error': { + 'status': 'INTERNAL', + 'message': 'test INTERNAL error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'SOME_UNKNOWN_CODE', + }, + ], + } + }) + + responses = repeat(respx.MockResponse(500, http_version='HTTP/2', content=payload)) + + msg1 = messaging.Message(topic='foo1') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + batch_response = await messaging.send_each_async([msg1], dry_run=True) + + assert route.call_count == 5 + assert batch_response.success_count == 0 + assert batch_response.failure_count == 1 + assert len(batch_response.responses) == 1 + exception = batch_response.responses[0].exception + assert isinstance(exception, exceptions.InternalError) + + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_async_error_500_pass_on_retry_config(self): + payload = json.dumps({ + 'error': { + 'status': 'INTERNAL', + 'message': 'test INTERNAL error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'SOME_UNKNOWN_CODE', + }, + ], + } + }) + responses = chain( + [ + respx.MockResponse(500, http_version='HTTP/2', content=payload), + respx.MockResponse(500, http_version='HTTP/2', content=payload), + respx.MockResponse(500, http_version='HTTP/2', content=payload), + respx.MockResponse(500, http_version='HTTP/2', content=payload), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + ], + ) + + msg1 = messaging.Message(topic='foo1') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + batch_response = await messaging.send_each_async([msg1], dry_run=True) - enforcement = _helpers.positional_parameters_enforcement - _helpers.positional_parameters_enforcement = _helpers.POSITIONAL_EXCEPTION - try: - batch_response = messaging.send_all([msg, msg], dry_run=True) - assert batch_response.success_count == 2 - finally: - _helpers.positional_parameters_enforcement = enforcement + assert route.call_count == 5 + assert batch_response.success_count == 1 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 1 + assert [r.message_id for r in batch_response.responses] == ['message-id1'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + + @pytest.mark.asyncio + @respx.mock + async def test_send_each_async_request_error(self): + responses = httpx.ConnectError("Test request error", request=httpx.Request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send')) + + msg1 = messaging.Message(topic='foo1') + route = respx.request( + 'POST', + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ).mock(side_effect=responses) + batch_response = await messaging.send_each_async([msg1], dry_run=True) + + assert route.call_count == 1 + assert batch_response.success_count == 0 + assert batch_response.failure_count == 1 + assert len(batch_response.responses) == 1 + exception = batch_response.responses[0].exception + assert isinstance(exception, exceptions.UnavailableError) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_all_detailed_error(self, status): + def test_send_each_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -1891,10 +2099,11 @@ def test_send_all_detailed_error(self, status): 'message': 'test error' } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg, msg]) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg1 = messaging.Message(topic='foo1') + msg2 = messaging.Message(topic='foo2') + batch_response = messaging.send_each([msg1, msg2]) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -1910,7 +2119,7 @@ def test_send_all_detailed_error(self, status): check_exception(exception, 'test error', status) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_all_canonical_error_code(self, status): + def test_send_each_canonical_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -1918,10 +2127,11 @@ def test_send_all_canonical_error_code(self, status): 'message': 'test error' } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg, msg]) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg1 = messaging.Message(topic='foo1') + msg2 = messaging.Message(topic='foo2') + batch_response = messaging.send_each([msg1, msg2]) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -1938,7 +2148,7 @@ def test_send_all_canonical_error_code(self, status): @pytest.mark.parametrize('status', HTTP_ERROR_CODES) @pytest.mark.parametrize('fcm_error_code, exc_type', FCM_ERROR_CODES.items()) - def test_send_all_fcm_error_code(self, status, fcm_error_code, exc_type): + def test_send_each_fcm_error_code(self, status, fcm_error_code, exc_type): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -1952,10 +2162,11 @@ def test_send_all_fcm_error_code(self, status, fcm_error_code, exc_type): ], } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.Message(topic='foo') - batch_response = messaging.send_all([msg, msg]) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg1 = messaging.Message(topic='foo1') + msg2 = messaging.Message(topic='foo2') + batch_response = messaging.send_each([msg1, msg2]) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -1970,95 +2181,39 @@ def test_send_all_fcm_error_code(self, status, fcm_error_code, exc_type): assert isinstance(exception, exc_type) check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_send_all_batch_error(self, status, exc_type): - _ = self._instrument_batch_messaging_service(status=status, payload='{}') - msg = messaging.Message(topic='foo') - with pytest.raises(exc_type) as excinfo: - messaging.send_all([msg]) - expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) - check_exception(excinfo.value, expected, status) - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_all_batch_detailed_error(self, status): - payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.Message(topic='foo') - with pytest.raises(exceptions.InvalidArgumentError) as excinfo: - messaging.send_all([msg]) - check_exception(excinfo.value, 'test error', status) - - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_all_batch_canonical_error_code(self, status): - payload = json.dumps({ - 'error': { - 'status': 'NOT_FOUND', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.Message(topic='foo') - with pytest.raises(exceptions.NotFoundError) as excinfo: - messaging.send_all([msg]) - check_exception(excinfo.value, 'test error', status) - - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_all_batch_fcm_error_code(self, status): - payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error', - 'details': [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.Message(topic='foo') - with pytest.raises(messaging.UnregisteredError) as excinfo: - messaging.send_all([msg]) - check_exception(excinfo.value, 'test error', status) - - -class TestSendMulticast(TestBatch): +class TestSendEachForMulticast(TestSendEach): def test_no_project_id(self): def evaluate(): app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') with pytest.raises(ValueError): - messaging.send_all([messaging.Message(topic='foo')], app=app) + messaging.send_each([messaging.Message(topic='foo')], app=app) testutils.run_without_project_id(evaluate) @pytest.mark.parametrize('msg', NON_LIST_ARGS) - def test_invalid_send_multicast(self, msg): + def test_invalid_send_each_for_multicast(self, msg): with pytest.raises(ValueError) as excinfo: - messaging.send_multicast(msg) + messaging.send_each_for_multicast(msg) expected = 'Message must be an instance of messaging.MulticastMessage class.' assert str(excinfo.value) == expected - def test_send_multicast(self): - payload = json.dumps({'name': 'message-id'}) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, payload), (200, payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - batch_response = messaging.send_multicast(msg, dry_run=True) + def test_send_each_for_multicast(self): + payload1 = json.dumps({'name': 'message-id1'}) + payload2 = json.dumps({'name': 'message-id2'}) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, payload1], 'foo2': [200, payload2]}) + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg, dry_run=True) assert batch_response.success_count == 2 assert batch_response.failure_count == 0 assert len(batch_response.responses) == 2 - assert [r.message_id for r in batch_response.responses] == ['message-id', 'message-id'] - assert all([r.success for r in batch_response.responses]) - assert not any([r.exception for r in batch_response.responses]) + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_detailed_error(self, status): + def test_send_each_for_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -2066,10 +2221,10 @@ def test_send_multicast_detailed_error(self, status): 'message': 'test error' } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - batch_response = messaging.send_multicast(msg) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -2086,7 +2241,7 @@ def test_send_multicast_detailed_error(self, status): check_exception(exception, 'test error', status) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_canonical_error_code(self, status): + def test_send_each_for_multicast_canonical_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -2094,10 +2249,10 @@ def test_send_multicast_canonical_error_code(self, status): 'message': 'test error' } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - batch_response = messaging.send_multicast(msg) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -2114,7 +2269,7 @@ def test_send_multicast_canonical_error_code(self, status): check_exception(exception, 'test error', status) @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_fcm_error_code(self, status): + def test_send_each_for_multicast_fcm_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -2128,10 +2283,10 @@ def test_send_multicast_fcm_error_code(self, status): ], } }) - _ = self._instrument_batch_messaging_service( - payload=self._batch_payload([(200, success_payload), (status, error_payload)])) - msg = messaging.MulticastMessage(tokens=['foo', 'foo']) - batch_response = messaging.send_multicast(msg) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, success_payload], 'foo2': [status, error_payload]}) + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg) assert batch_response.success_count == 1 assert batch_response.failure_count == 1 assert len(batch_response.responses) == 2 @@ -2147,63 +2302,6 @@ def test_send_multicast_fcm_error_code(self, status): assert isinstance(exception, messaging.UnregisteredError) check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_send_multicast_batch_error(self, status, exc_type): - _ = self._instrument_batch_messaging_service(status=status, payload='{}') - msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(exc_type) as excinfo: - messaging.send_multicast(msg) - expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) - check_exception(excinfo.value, expected, status) - - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_batch_detailed_error(self, status): - payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(exceptions.InvalidArgumentError) as excinfo: - messaging.send_multicast(msg) - check_exception(excinfo.value, 'test error', status) - - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_batch_canonical_error_code(self, status): - payload = json.dumps({ - 'error': { - 'status': 'NOT_FOUND', - 'message': 'test error' - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(exceptions.NotFoundError) as excinfo: - messaging.send_multicast(msg) - check_exception(excinfo.value, 'test error', status) - - @pytest.mark.parametrize('status', HTTP_ERROR_CODES) - def test_send_multicast_batch_fcm_error_code(self, status): - payload = json.dumps({ - 'error': { - 'status': 'INVALID_ARGUMENT', - 'message': 'test error', - 'details': [ - { - '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', - }, - ], - } - }) - _ = self._instrument_batch_messaging_service(status=status, payload=payload) - msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(messaging.UnregisteredError) as excinfo: - messaging.send_multicast(msg) - check_exception(excinfo.value, 'test error', status) - class TestTopicManagement: @@ -2242,10 +2340,17 @@ def _instrument_iid_service(self, app=None, status=200, payload=_DEFAULT_RESPONS testutils.MockAdapter(payload, status, recorder)) return fcm_service, recorder + def _assert_request(self, request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['access_token_auth'] == 'true' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + def _get_url(self, path): - return '{0}/{1}'.format(messaging._MessagingService.IID_URL, path) + return f'{messaging._MessagingService.IID_URL}/{path}' - @pytest.mark.parametrize('tokens', [None, '', list(), dict(), tuple()]) + @pytest.mark.parametrize('tokens', [None, '', [], {}, tuple()]) def test_invalid_tokens(self, tokens): expected = 'Tokens must be a string or a non-empty list of strings.' if isinstance(tokens, str): @@ -2276,8 +2381,7 @@ def test_subscribe_to_topic(self, args): resp = messaging.subscribe_to_topic(args[0], args[1]) self._check_response(resp) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchAdd') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) assert json.loads(recorder[0].body.decode()) == args[2] @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) @@ -2288,19 +2392,17 @@ def test_subscribe_to_topic_error(self, status, exc_type): messaging.subscribe_to_topic('foo', 'test-topic') assert str(excinfo.value) == 'Error while calling the IID service: error_reason' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchAdd') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) def test_subscribe_to_topic_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') with pytest.raises(exc_type) as excinfo: messaging.subscribe_to_topic('foo', 'test-topic') - reason = 'Unexpected HTTP response with status: {0}; body: not json'.format(status) + reason = f'Unexpected HTTP response with status: {status}; body: not json' assert str(excinfo.value) == reason assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchAdd') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) @pytest.mark.parametrize('args', _VALID_ARGS) def test_unsubscribe_from_topic(self, args): @@ -2308,8 +2410,7 @@ def test_unsubscribe_from_topic(self, args): resp = messaging.unsubscribe_from_topic(args[0], args[1]) self._check_response(resp) assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchRemove') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchRemove')) assert json.loads(recorder[0].body.decode()) == args[2] @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) @@ -2320,19 +2421,17 @@ def test_unsubscribe_from_topic_error(self, status, exc_type): messaging.unsubscribe_from_topic('foo', 'test-topic') assert str(excinfo.value) == 'Error while calling the IID service: error_reason' assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchRemove') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchRemove')) @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) def test_unsubscribe_from_topic_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') with pytest.raises(exc_type) as excinfo: messaging.unsubscribe_from_topic('foo', 'test-topic') - reason = 'Unexpected HTTP response with status: {0}; body: not json'.format(status) + reason = f'Unexpected HTTP response with status: {status}; body: not json' assert str(excinfo.value) == reason assert len(recorder) == 1 - assert recorder[0].method == 'POST' - assert recorder[0].url == self._get_url('iid/v1:batchRemove') + self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchRemove')) def _check_response(self, resp): assert resp.success_count == 1 diff --git a/tests/test_ml.py b/tests/test_ml.py index 10b0441db..bcc93fd05 100644 --- a/tests/test_ml.py +++ b/tests/test_ml.py @@ -21,12 +21,11 @@ import firebase_admin from firebase_admin import exceptions from firebase_admin import ml +from firebase_admin import _utils from tests import testutils BASE_URL = 'https://firebaseml.googleapis.com/v1beta2/' -HEADER_CLIENT_KEY = 'X-FIREBASE-CLIENT' -HEADER_CLIENT_VALUE = 'fire-admin-python/{0}'.format(firebase_admin.__version__) PROJECT_ID = 'my-project-1' PAGE_TOKEN = 'pageToken' @@ -50,7 +49,7 @@ TAGS_2 = [TAG_1, TAG_3] MODEL_ID_1 = 'modelId1' -MODEL_NAME_1 = 'projects/{0}/models/{1}'.format(PROJECT_ID, MODEL_ID_1) +MODEL_NAME_1 = f'projects/{PROJECT_ID}/models/{MODEL_ID_1}' DISPLAY_NAME_1 = 'displayName1' MODEL_JSON_1 = { 'name': MODEL_NAME_1, @@ -59,7 +58,7 @@ MODEL_1 = ml.Model.from_dict(MODEL_JSON_1) MODEL_ID_2 = 'modelId2' -MODEL_NAME_2 = 'projects/{0}/models/{1}'.format(PROJECT_ID, MODEL_ID_2) +MODEL_NAME_2 = f'projects/{PROJECT_ID}/models/{MODEL_ID_2}' DISPLAY_NAME_2 = 'displayName2' MODEL_JSON_2 = { 'name': MODEL_NAME_2, @@ -68,7 +67,7 @@ MODEL_2 = ml.Model.from_dict(MODEL_JSON_2) MODEL_ID_3 = 'modelId3' -MODEL_NAME_3 = 'projects/{0}/models/{1}'.format(PROJECT_ID, MODEL_ID_3) +MODEL_NAME_3 = f'projects/{PROJECT_ID}/models/{MODEL_ID_3}' DISPLAY_NAME_3 = 'displayName3' MODEL_JSON_3 = { 'name': MODEL_NAME_3, @@ -80,7 +79,7 @@ 'published': True } VALIDATION_ERROR_CODE = 400 -VALIDATION_ERROR_MSG = 'No model format found for {0}.'.format(MODEL_ID_1) +VALIDATION_ERROR_MSG = f'No model format found for {MODEL_ID_1}.' MODEL_STATE_ERROR_JSON = { 'validationError': { 'code': VALIDATION_ERROR_CODE, @@ -88,19 +87,19 @@ } } -OPERATION_NAME_1 = 'projects/{0}/operations/123'.format(PROJECT_ID) +OPERATION_NAME_1 = f'projects/{PROJECT_ID}/operations/123' OPERATION_NOT_DONE_JSON_1 = { 'name': OPERATION_NAME_1, 'metadata': { '@type': 'type.googleapis.com/google.firebase.ml.v1beta2.ModelOperationMetadata', - 'name': 'projects/{0}/models/{1}'.format(PROJECT_ID, MODEL_ID_1), + 'name': f'projects/{PROJECT_ID}/models/{MODEL_ID_1}', 'basic_operation_status': 'BASIC_OPERATION_STATUS_UPLOADING' } } GCS_BUCKET_NAME = 'my_bucket' GCS_BLOB_NAME = 'mymodel.tflite' -GCS_TFLITE_URI = 'gs://{0}/{1}'.format(GCS_BUCKET_NAME, GCS_BLOB_NAME) +GCS_TFLITE_URI = f'gs://{GCS_BUCKET_NAME}/{GCS_BLOB_NAME}' GCS_TFLITE_URI_JSON = {'gcsTfliteUri': GCS_TFLITE_URI} GCS_TFLITE_MODEL_SOURCE = ml.TFLiteGCSModelSource(GCS_TFLITE_URI) TFLITE_FORMAT_JSON = { @@ -258,8 +257,8 @@ INVALID_MODEL_ARGS = [ 'abc', 4.2, - list(), - dict(), + [], + {}, True, -1, 0, @@ -273,9 +272,10 @@ 'projects/$#@/operations/123', 'projects/1234/operations/123/extrathing', ] -PAGE_SIZE_VALUE_ERROR_MSG = 'Page size must be a positive integer between ' \ - '1 and {0}'.format(ml._MAX_PAGE_SIZE) -INVALID_STRING_OR_NONE_ARGS = [0, -1, 4.2, 0x10, False, list(), dict()] +PAGE_SIZE_VALUE_ERROR_MSG = ( + f'Page size must be a positive integer between 1 and {ml._MAX_PAGE_SIZE}' +) +INVALID_STRING_OR_NONE_ARGS = [0, -1, 4.2, 0x10, False, [], {}] # For validation type errors @@ -324,6 +324,13 @@ def instrument_ml_service(status=200, payload=None, operations=False, app=None): session_url, adapter(payload, status, recorder)) return recorder +def _assert_request(request, expected_method, expected_url): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['X-FIREBASE-CLIENT'] == f'fire-admin-python/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + class _TestStorageClient: @staticmethod def upload(bucket_name, model_file_name, app): @@ -352,8 +359,7 @@ def teardown_class(cls): @staticmethod def _op_url(project_id): - return BASE_URL + \ - 'projects/{0}/operations/123'.format(project_id) + return BASE_URL + f'projects/{project_id}/operations/123' def test_model_success_err_state_lro(self): model = ml.Model.from_dict(FULL_MODEL_ERR_STATE_LRO_JSON) @@ -405,7 +411,7 @@ def test_model_keyword_based_creation_and_setters(self): 'tfliteModel': TFLITE_FORMAT_JSON_2 } - def test_model_format_source_creation(self): + def test_gcs_tflite_model_format_source_creation(self): model_source = ml.TFLiteGCSModelSource(gcs_tflite_uri=GCS_TFLITE_URI) model_format = ml.TFLiteFormat(model_source=model_source) model = ml.Model(display_name=DISPLAY_NAME_1, model_format=model_format) @@ -423,7 +429,7 @@ def test_source_creation_from_tflite_file(self): 'gcsTfliteUri': 'gs://my_bucket/Firebase/ML/Models/my_model.tflite' } - def test_model_source_setters(self): + def test_gcs_tflite_model_source_setters(self): model_source = ml.TFLiteGCSModelSource(GCS_TFLITE_URI) model_source.gcs_tflite_uri = GCS_TFLITE_URI_2 assert model_source.gcs_tflite_uri == GCS_TFLITE_URI_2 @@ -536,9 +542,7 @@ def test_wait_for_unlocked(self): model.wait_for_unlocked() assert model == FULL_MODEL_PUBLISHED assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == TestModel._op_url(PROJECT_ID) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'GET', TestModel._op_url(PROJECT_ID)) def test_wait_for_unlocked_timeout(self): recorder = instrument_ml_service( @@ -565,16 +569,15 @@ def teardown_class(cls): @staticmethod def _url(project_id): - return BASE_URL + 'projects/{0}/models'.format(project_id) + return BASE_URL + f'projects/{project_id}/models' @staticmethod def _op_url(project_id): - return BASE_URL + \ - 'projects/{0}/operations/123'.format(project_id) + return BASE_URL + f'projects/{project_id}/operations/123' @staticmethod def _get_url(project_id, model_id): - return BASE_URL + 'projects/{0}/models/{1}'.format(project_id, model_id) + return BASE_URL + f'projects/{project_id}/models/{model_id}' def test_immediate_done(self): instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) @@ -590,12 +593,8 @@ def test_returns_locked(self): assert model == expected_model assert len(recorder) == 2 - assert recorder[0].method == 'POST' - assert recorder[0].url == TestCreateModel._url(PROJECT_ID) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE - assert recorder[1].method == 'GET' - assert recorder[1].url == TestCreateModel._get_url(PROJECT_ID, MODEL_ID_1) - assert recorder[1].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'POST', TestCreateModel._url(PROJECT_ID)) + _assert_request(recorder[1], 'GET', TestCreateModel._get_url(PROJECT_ID, MODEL_ID_1)) def test_operation_error(self): instrument_ml_service(status=200, payload=OPERATION_ERROR_RESPONSE) @@ -663,12 +662,11 @@ def teardown_class(cls): @staticmethod def _url(project_id, model_id): - return BASE_URL + 'projects/{0}/models/{1}'.format(project_id, model_id) + return BASE_URL + f'projects/{project_id}/models/{model_id}' @staticmethod def _op_url(project_id): - return BASE_URL + \ - 'projects/{0}/operations/123'.format(project_id) + return BASE_URL + f'projects/{project_id}/operations/123' def test_immediate_done(self): instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) @@ -684,12 +682,8 @@ def test_returns_locked(self): assert model == expected_model assert len(recorder) == 2 - assert recorder[0].method == 'PATCH' - assert recorder[0].url == TestUpdateModel._url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE - assert recorder[1].method == 'GET' - assert recorder[1].url == TestUpdateModel._url(PROJECT_ID, MODEL_ID_1) - assert recorder[1].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'PATCH', TestUpdateModel._url(PROJECT_ID, MODEL_ID_1)) + _assert_request(recorder[1], 'GET', TestUpdateModel._url(PROJECT_ID, MODEL_ID_1)) def test_operation_error(self): instrument_ml_service(status=200, payload=OPERATION_ERROR_RESPONSE) @@ -764,18 +758,16 @@ def teardown_class(cls): @staticmethod def _update_url(project_id, model_id): - update_url = 'projects/{0}/models/{1}?updateMask=state.published'.format( - project_id, model_id) + update_url = f'projects/{project_id}/models/{model_id}?updateMask=state.published' return BASE_URL + update_url @staticmethod def _get_url(project_id, model_id): - return BASE_URL + 'projects/{0}/models/{1}'.format(project_id, model_id) + return BASE_URL + f'projects/{project_id}/models/{model_id}' @staticmethod def _op_url(project_id): - return BASE_URL + \ - 'projects/{0}/operations/123'.format(project_id) + return BASE_URL + f'projects/{project_id}/operations/123' @pytest.mark.parametrize('publish_function, published', PUBLISH_UNPUBLISH_WITH_ARGS) def test_immediate_done(self, publish_function, published): @@ -783,9 +775,8 @@ def test_immediate_done(self, publish_function, published): model = publish_function(MODEL_ID_1) assert model == CREATED_UPDATED_MODEL_1 assert len(recorder) == 1 - assert recorder[0].method == 'PATCH' - assert recorder[0].url == TestPublishUnpublish._update_url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request( + recorder[0], 'PATCH', TestPublishUnpublish._update_url(PROJECT_ID, MODEL_ID_1)) body = json.loads(recorder[0].body.decode()) assert body.get('state', {}).get('published', None) is published @@ -799,12 +790,10 @@ def test_returns_locked(self, publish_function): assert model == expected_model assert len(recorder) == 2 - assert recorder[0].method == 'PATCH' - assert recorder[0].url == TestPublishUnpublish._update_url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE - assert recorder[1].method == 'GET' - assert recorder[1].url == TestPublishUnpublish._get_url(PROJECT_ID, MODEL_ID_1) - assert recorder[1].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request( + recorder[0], 'PATCH', TestPublishUnpublish._update_url(PROJECT_ID, MODEL_ID_1)) + _assert_request( + recorder[1], 'GET', TestPublishUnpublish._get_url(PROJECT_ID, MODEL_ID_1)) @pytest.mark.parametrize('publish_function', PUBLISH_UNPUBLISH_FUNCS) def test_operation_error(self, publish_function): @@ -849,15 +838,13 @@ def teardown_class(cls): @staticmethod def _url(project_id, model_id): - return BASE_URL + 'projects/{0}/models/{1}'.format(project_id, model_id) + return BASE_URL + f'projects/{project_id}/models/{model_id}' def test_get_model(self): recorder = instrument_ml_service(status=200, payload=DEFAULT_GET_RESPONSE) model = ml.get_model(MODEL_ID_1) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == TestGetModel._url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'GET', TestGetModel._url(PROJECT_ID, MODEL_ID_1)) assert model == MODEL_1 assert model.model_id == MODEL_ID_1 assert model.display_name == DISPLAY_NAME_1 @@ -879,9 +866,7 @@ def test_get_model_error(self): ERROR_MSG_NOT_FOUND ) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == TestGetModel._url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'GET', TestGetModel._url(PROJECT_ID, MODEL_ID_1)) def test_no_project_id(self): def evaluate(): @@ -904,15 +889,13 @@ def teardown_class(cls): @staticmethod def _url(project_id, model_id): - return BASE_URL + 'projects/{0}/models/{1}'.format(project_id, model_id) + return BASE_URL + f'projects/{project_id}/models/{model_id}' def test_delete_model(self): recorder = instrument_ml_service(status=200, payload=EMPTY_RESPONSE) ml.delete_model(MODEL_ID_1) # no response for delete assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == TestDeleteModel._url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'DELETE', TestDeleteModel._url(PROJECT_ID, MODEL_ID_1)) @pytest.mark.parametrize('model_id, exc_type', INVALID_MODEL_ID_ARGS) def test_delete_model_validation_errors(self, model_id, exc_type): @@ -931,9 +914,7 @@ def test_delete_model_error(self): ERROR_MSG_NOT_FOUND ) assert len(recorder) == 1 - assert recorder[0].method == 'DELETE' - assert recorder[0].url == self._url(PROJECT_ID, MODEL_ID_1) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'DELETE', self._url(PROJECT_ID, MODEL_ID_1)) def test_no_project_id(self): def evaluate(): @@ -956,7 +937,7 @@ def teardown_class(cls): @staticmethod def _url(project_id): - return BASE_URL + 'projects/{0}/models'.format(project_id) + return BASE_URL + f'projects/{project_id}/models' @staticmethod def _check_page(page, model_count): @@ -969,9 +950,7 @@ def test_list_models_no_args(self): recorder = instrument_ml_service(status=200, payload=DEFAULT_LIST_RESPONSE) models_page = ml.list_models() assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == TestListModels._url(PROJECT_ID) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'GET', TestListModels._url(PROJECT_ID)) TestListModels._check_page(models_page, 2) assert models_page.has_next_page assert models_page.next_page_token == NEXT_PAGE_TOKEN @@ -985,12 +964,10 @@ def test_list_models_with_all_args(self): page_size=10, page_token=PAGE_TOKEN) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == ( + _assert_request(recorder[0], 'GET', ( TestListModels._url(PROJECT_ID) + - '?filter=display_name%3DdisplayName3&page_size=10&page_token={0}' - .format(PAGE_TOKEN)) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + f'?filter=display_name%3DdisplayName3&page_size=10&page_token={PAGE_TOKEN}' + )) assert isinstance(models_page, ml.ListModelsPage) assert len(models_page.models) == 1 assert models_page.models[0] == MODEL_3 @@ -1005,8 +982,8 @@ def test_list_models_list_filter_validation(self, list_filter): @pytest.mark.parametrize('page_size, exc_type, error_message', [ ('abc', TypeError, 'Page size must be a number or None.'), (4.2, TypeError, 'Page size must be a number or None.'), - (list(), TypeError, 'Page size must be a number or None.'), - (dict(), TypeError, 'Page size must be a number or None.'), + ([], TypeError, 'Page size must be a number or None.'), + ({}, TypeError, 'Page size must be a number or None.'), (True, TypeError, 'Page size must be a number or None.'), (-1, ValueError, PAGE_SIZE_VALUE_ERROR_MSG), (0, ValueError, PAGE_SIZE_VALUE_ERROR_MSG), @@ -1034,9 +1011,7 @@ def test_list_models_error(self): ERROR_MSG_BAD_REQUEST ) assert len(recorder) == 1 - assert recorder[0].method == 'GET' - assert recorder[0].url == TestListModels._url(PROJECT_ID) - assert recorder[0].headers[HEADER_CLIENT_KEY] == HEADER_CLIENT_VALUE + _assert_request(recorder[0], 'GET', TestListModels._url(PROJECT_ID)) def test_no_project_id(self): def evaluate(): @@ -1052,7 +1027,7 @@ def test_list_single_page(self): assert models_page.next_page_token == '' assert models_page.has_next_page is False assert models_page.get_next_page() is None - models = [model for model in models_page.iterate_all()] + models = list(models_page.iterate_all()) assert len(models) == 1 def test_list_multiple_pages(self): @@ -1082,7 +1057,7 @@ def test_list_models_paged_iteration(self): iterator = page.iterate_all() for index in range(2): model = next(iterator) - assert model.display_name == 'displayName{0}'.format(index+1) + assert model.display_name == f'displayName{index+1}' assert len(recorder) == 1 # Page 2 @@ -1098,7 +1073,7 @@ def test_list_models_stop_iteration(self): assert len(recorder) == 1 assert len(page.models) == 3 iterator = page.iterate_all() - models = [model for model in iterator] + models = list(iterator) assert len(page.models) == 3 with pytest.raises(StopIteration): next(iterator) @@ -1109,5 +1084,5 @@ def test_list_models_no_models(self): page = ml.list_models() assert len(recorder) == 1 assert len(page.models) == 0 - models = [model for model in page.iterate_all()] + models = list(page.iterate_all()) assert len(models) == 0 diff --git a/tests/test_project_management.py b/tests/test_project_management.py index 183195510..89e48c2e5 100644 --- a/tests/test_project_management.py +++ b/tests/test_project_management.py @@ -23,6 +23,7 @@ from firebase_admin import exceptions from firebase_admin import project_management from firebase_admin import _http_client +from firebase_admin import _utils from tests import testutils OPERATION_IN_PROGRESS_RESPONSE = json.dumps({ @@ -521,8 +522,9 @@ def _assert_request_is_correct( self, request, expected_method, expected_url, expected_body=None): assert request.method == expected_method assert request.url == expected_url - client_version = 'Python/Admin/{0}'.format(firebase_admin.__version__) - assert request.headers['X-Client-Version'] == client_version + assert request.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header if expected_body is None: assert request.body is None else: @@ -543,7 +545,7 @@ def test_custom_timeout(self, timeout): 'projectId': 'test-project-id' } app = firebase_admin.initialize_app( - testutils.MockCredential(), options, 'timeout-{0}'.format(timeout)) + testutils.MockCredential(), options, f'timeout-{timeout}') project_management_service = project_management._get_project_management_service(app) assert project_management_service._client.timeout == timeout @@ -818,7 +820,7 @@ def test_list_android_apps_rpc_error(self): assert len(recorder) == 1 def test_list_android_apps_empty_list(self): - recorder = self._instrument_service(statuses=[200], responses=[json.dumps(dict())]) + recorder = self._instrument_service(statuses=[200], responses=[json.dumps({})]) android_apps = project_management.list_android_apps() @@ -881,7 +883,7 @@ def test_list_ios_apps_rpc_error(self): assert len(recorder) == 1 def test_list_ios_apps_empty_list(self): - recorder = self._instrument_service(statuses=[200], responses=[json.dumps(dict())]) + recorder = self._instrument_service(statuses=[200], responses=[json.dumps({})]) ios_apps = project_management.list_ios_apps() diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py new file mode 100644 index 000000000..7bbf9721d --- /dev/null +++ b/tests/test_remote_config.py @@ -0,0 +1,984 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for firebase_admin.remote_config.""" +import json +import uuid +import pytest +import firebase_admin +from firebase_admin.remote_config import ( + CustomSignalOperator, + PercentConditionOperator, + _REMOTE_CONFIG_ATTRIBUTE, + _RemoteConfigService) +from firebase_admin import remote_config, _utils +from tests import testutils + +VERSION_INFO = { + 'versionNumber': '86', + 'updateOrigin': 'ADMIN_SDK_PYTHON', + 'updateType': 'INCREMENTAL_UPDATE', + 'updateUser': { + 'email': 'firebase-adminsdk@gserviceaccount.com' + }, + 'description': 'production version', + 'updateTime': '2024-11-05T16:45:03.541527Z' + } + +SERVER_REMOTE_CONFIG_RESPONSE = { + 'conditions': [ + { + 'name': 'ios', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + {'true': {}} + ] + } + } + ] + } + } + }, + ], + 'parameters': { + 'holiday_promo_enabled': { + 'defaultValue': {'value': 'true'}, + 'conditionalValues': {'ios': {'useInAppDefault': 'true'}} + }, + }, + 'parameterGroups': '', + 'etag': 'etag-123456789012-5', + 'version': VERSION_INFO, + } + +SEMENTIC_VERSION_LESS_THAN_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.443', True] +SEMENTIC_VERSION_EQUAL_TRUE = [ + CustomSignalOperator.SEMANTIC_VERSION_EQUAL.value, ['12.1.3.444'], '12.1.3.444', True] +SEMANTIC_VERSION_GREATER_THAN_FALSE = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.4'], '12.1.3.4', False] +SEMANTIC_VERSION_INVALID_FORMAT_STRING = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.abc', False] +SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER = [ + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN.value, ['12.1.3.444'], '12.1.3.-2', False] + +class TestEvaluate: + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_evaluate_or_and_true_condition_true(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'true': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': 'etag' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + assert server_config.get_value_source('is_enabled') == 'remote' + + def test_evaluate_or_and_false_condition_false(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'name': '', + 'false': { + } + } + ] + } + } + ] + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': 'etag' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + + server_config = server_template.evaluate() + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_non_or_condition(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + condition = { + 'name': 'is_true', + 'condition': { + 'true': { + } + } + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups': '', + 'version': '', + 'etag': 'etag' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + + server_config = server_template.evaluate() + assert server_config.get_boolean('is_enabled') + + def test_evaluate_return_conditional_values_honor_order(self): + app = firebase_admin.get_app() + default_config = {'param1': 'in_app_default_param1', 'param3': 'in_app_default_param3'} + template_data = { + 'conditions': [ + { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + }, + { + 'name': 'is_true_too', + 'condition': { + 'orCondition': { + 'conditions': [ + { + 'andCondition': { + 'conditions': [ + { + 'true': { + } + } + ] + } + } + ] + } + } + } + ], + 'parameters': { + 'dog_type': { + 'defaultValue': {'value': 'chihuahua'}, + 'conditionalValues': { + 'is_true_too': {'value': 'dachshund'}, + 'is_true': {'value': 'corgi'} + } + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': 'etag' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'corgi' + + def test_evaluate_default_when_no_param(self): + app = firebase_admin.get_app() + default_config = {'promo_enabled': False, 'promo_discount': '20',} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('promo_enabled') == default_config.get('promo_enabled') + assert server_config.get_int('promo_discount') == int(default_config.get('promo_discount')) + + def test_evaluate_default_when_no_default_value(self): + app = firebase_admin.get_app() + default_config = {'default_value': 'local default'} + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'default_value': {} + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('default_value') == default_config.get('default_value') + + def test_evaluate_default_when_in_default(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = { + 'remote_default_value': {} + } + default_config = { + 'inapp_default': '🐕' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('inapp_default') == default_config.get('inapp_default') + + def test_evaluate_default_when_defined(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + template_data['parameters'] = {} + default_config = { + 'dog_type': 'shiba' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_string('dog_type') == 'shiba' + + def test_evaluate_return_numeric_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_age': '12' + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_int('dog_age') == int(default_config.get('dog_age')) + + def test_evaluate_return_boolean_value(self): + app = firebase_admin.get_app() + template_data = SERVER_REMOTE_CONFIG_RESPONSE + default_config = { + 'dog_is_cute': True + } + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate() + assert server_config.get_boolean('dog_is_cute') + + def test_evaluate_unknown_operator_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.UNKNOWN.value + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, + 'seed': 'abcdef', + 'microPercent': 100_000_000 + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercent_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_undefined_micropercentrange_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN.value, + # Leaves microPercent undefined + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_between_min_max_to_true(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN.value, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 0, + 'microPercentUpperBound': 100_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') + + def test_evaluate_between_equal_bounds_to_false(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN.value, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 50000000, + 'microPercentUpperBound': 50000000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123'} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert not server_config.get_boolean('is_enabled') + + def test_evaluate_less_or_equal_to_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.LESS_OR_EQUAL.value, + 'seed': 'abcdef', + 'microPercent': 10_000_000 # 10% + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 284 + assert truthy_assignments >= 10000 - tolerance + assert truthy_assignments <= 10000 + tolerance + + def test_evaluate_between_approx(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN.value, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 40_000_000, + 'microPercentUpperBound': 60_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 379 + assert truthy_assignments >= 20000 - tolerance + assert truthy_assignments <= 20000 + tolerance + + def test_evaluate_between_interquartile_range_accuracy(self): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'percent': { + 'percentOperator': PercentConditionOperator.BETWEEN.value, + 'seed': 'abcdef', + 'microPercentRange': { + 'microPercentLowerBound': 25_000_000, + 'microPercentUpperBound': 75_000_000 + } + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + + truthy_assignments = self.evaluate_random_assignments(condition, 100000, + app, default_config) + tolerance = 490 + assert truthy_assignments >= 50000 - tolerance + assert truthy_assignments <= 50000 + tolerance + + def evaluate_random_assignments(self, condition, num_of_assignments, mock_app, default_config): + """Evaluates random assignments based on a condition. + + Args: + condition: The condition to evaluate. + num_of_assignments: The number of assignments to generate. + condition_evaluator: An instance of the ConditionEvaluator class. + + Returns: + int: The number of assignments that evaluated to true. + """ + eval_true_count = 0 + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + server_template = remote_config.init_server_template( + app=mock_app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + + for _ in range(num_of_assignments): + context = {'randomization_id': str(uuid.uuid4())} + result = server_template.evaluate(context) + if result.get_boolean('is_enabled') is True: + eval_true_count += 1 + + return eval_true_count + + @pytest.mark.parametrize( + 'custom_signal_opearator, \ + target_custom_signal_value, actual_custom_signal_value, parameter_value', + [ + SEMENTIC_VERSION_LESS_THAN_TRUE, + SEMANTIC_VERSION_GREATER_THAN_FALSE, + SEMENTIC_VERSION_EQUAL_TRUE, + SEMANTIC_VERSION_INVALID_FORMAT_NEGATIVE_INTEGER, + SEMANTIC_VERSION_INVALID_FORMAT_STRING + ]) + def test_evaluate_custom_signal_semantic_version(self, + custom_signal_opearator, + target_custom_signal_value, + actual_custom_signal_value, + parameter_value): + app = firebase_admin.get_app() + condition = { + 'name': 'is_true', + 'condition': { + 'orCondition': { + 'conditions': [{ + 'andCondition': { + 'conditions': [{ + 'customSignal': { + 'customSignalOperator': custom_signal_opearator, + 'customSignalKey': 'sementic_version_key', + 'targetCustomSignalValues': target_custom_signal_value + } + }], + } + }] + } + } + } + default_config = { + 'dog_is_cute': True + } + template_data = { + 'conditions': [condition], + 'parameters': { + 'is_enabled': { + 'defaultValue': {'value': 'false'}, + 'conditionalValues': {'is_true': {'value': 'true'}} + }, + }, + 'parameterGroups':'', + 'version':'', + 'etag': '123' + } + context = {'randomization_id': '123', 'sementic_version_key': actual_custom_signal_value} + server_template = remote_config.init_server_template( + app=app, + default_config=default_config, + template_data_json=json.dumps(template_data) + ) + server_config = server_template.evaluate(context) + assert server_config.get_boolean('is_enabled') == parameter_value + + +class MockAdapter(testutils.MockAdapter): + """A Mock HTTP Adapter that provides Firebase Remote Config responses with ETag in header.""" + + ETAG = 'etag' + + def __init__(self, data, status, recorder, etag=ETAG): + testutils.MockAdapter.__init__(self, data, status, recorder) + self._etag = etag + + def send(self, request, **kwargs): + resp = super().send(request, **kwargs) + resp.headers = {'etag': self._etag} + return resp + + +class TestRemoteConfigService: + """Tests methods on _RemoteConfigService""" + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + @pytest.mark.asyncio + async def test_rc_instance_get_server_template(self): + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': 'test_value' + }, + 'conditions': [], + 'version': 'test' + }) + + rc_instance = _utils.get_app_service(firebase_admin.get_app(), + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await rc_instance.get_server_template() + + assert template.parameters == {"test_key": 'test_value'} + assert str(template.version) == 'test' + assert str(template.etag) == 'etag' + + @pytest.mark.asyncio + async def test_rc_instance_get_server_template_empty_params(self): + recorder = [] + response = json.dumps({ + 'conditions': [], + 'version': 'test' + }) + + rc_instance = _utils.get_app_service(firebase_admin.get_app(), + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await rc_instance.get_server_template() + + assert template.parameters == {} + assert str(template.version) == 'test' + assert str(template.etag) == 'etag' + + +class TestRemoteConfigModule: + """Tests methods on firebase_admin.remote_config""" + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_init_server_template(self): + app = firebase_admin.get_app() + template_data = { + 'conditions': [], + 'parameters': { + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } + }, + 'version': '', + } + + template = remote_config.init_server_template( + app=app, + default_config={'default_test': 'default_value'}, + template_data_json=json.dumps(template_data) + ) + + config = template.evaluate() + assert config.get_string('test_key') == 'test_value' + + @pytest.mark.asyncio + async def test_get_server_template(self): + app = firebase_admin.get_app() + rc_instance = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } + }, + 'conditions': [], + 'version': 'test' + }) + + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + + template = await remote_config.get_server_template(app=app) + + config = template.evaluate() + assert config.get_string('test_key') == 'test_value' + + @pytest.mark.asyncio + async def test_server_template_to_json(self): + app = firebase_admin.get_app() + rc_instance = _utils.get_app_service(app, + _REMOTE_CONFIG_ATTRIBUTE, _RemoteConfigService) + + recorder = [] + response = json.dumps({ + 'parameters': { + 'test_key': { + 'defaultValue': {'value': 'test_value'}, + 'conditionalValues': {} + } + }, + 'conditions': [], + 'version': 'test' + }) + + expected_template_json = '{"parameters": {' \ + '"test_key": {' \ + '"defaultValue": {' \ + '"value": "test_value"}, ' \ + '"conditionalValues": {}}}, "conditions": [], ' \ + '"version": "test", "etag": "etag"}' + + rc_instance._client.session.mount( + 'https://firebaseremoteconfig.googleapis.com', + MockAdapter(response, 200, recorder)) + template = await remote_config.get_server_template(app=app) + + template_json = template.to_json() + assert template_json == expected_template_json diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 000000000..751fdea7b --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,454 @@ +# Copyright 2025 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the firebase_admin._retry module.""" + +import time +import email.utils +from itertools import repeat +from unittest.mock import call +import pytest +import httpx +from pytest_mock import MockerFixture +import respx + +from firebase_admin._retry import HttpxRetry, HttpxRetryTransport + +_TEST_URL = 'http://firebase.test.url/' + +@pytest.fixture +def base_url() -> str: + """Provides a consistent base URL for tests.""" + return _TEST_URL + +class TestHttpxRetryTransport(): + @pytest.mark.asyncio + @respx.mock + async def test_no_retry_on_success(self, base_url: str, mocker: MockerFixture): + """Test that a successful response doesn't trigger retries.""" + retry_config = HttpxRetry(max_retries=3, status_forcelist=[500]) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(return_value=httpx.Response(200, text="Success")) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert response.text == "Success" + assert route.call_count == 1 + mock_sleep.assert_not_called() + + @pytest.mark.asyncio + @respx.mock + async def test_no_retry_on_non_retryable_status(self, base_url: str, mocker: MockerFixture): + """Test that a non-retryable error status doesn't trigger retries.""" + retry_config = HttpxRetry(max_retries=3, status_forcelist=[500, 503]) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(return_value=httpx.Response(404, text="Not Found")) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 404 + assert response.text == "Not Found" + assert route.call_count == 1 + mock_sleep.assert_not_called() + + @pytest.mark.asyncio + @respx.mock + async def test_retry_on_status_code_success_on_last_retry( + self, base_url: str, mocker: MockerFixture + ): + """Test retry on status code from status_forcelist, succeeding on the last attempt.""" + retry_config = HttpxRetry(max_retries=2, status_forcelist=[503, 500], backoff_factor=0.5) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(503, text="Attempt 1 Failed"), + httpx.Response(500, text="Attempt 2 Failed"), + httpx.Response(200, text="Attempt 3 Success"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert response.text == "Attempt 3 Success" + assert route.call_count == 3 + assert mock_sleep.call_count == 2 + # Check sleep calls (backoff_factor is 0.5) + mock_sleep.assert_has_calls([call(0.0), call(1.0)]) + + @pytest.mark.asyncio + @respx.mock + async def test_retry_exhausted_returns_last_response( + self, base_url: str, mocker: MockerFixture + ): + """Test that the last response is returned when retries are exhausted.""" + retry_config = HttpxRetry(max_retries=1, status_forcelist=[500], backoff_factor=0) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(500, text="Attempt 1 Failed"), + httpx.Response(500, text="Attempt 2 Failed (Final)"), + # Should stop after previous response + httpx.Response(200, text="This should not be reached"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 500 + assert response.text == "Attempt 2 Failed (Final)" + assert route.call_count == 2 # Initial call + 1 retry + assert mock_sleep.call_count == 1 # Slept before the single retry + + @pytest.mark.asyncio + @respx.mock + async def test_retry_after_header_seconds(self, base_url: str, mocker: MockerFixture): + """Test respecting Retry-After header with seconds value.""" + retry_config = HttpxRetry( + max_retries=1, respect_retry_after_header=True, backoff_factor=100) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '10'}), + httpx.Response(200, text="OK"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 2 + assert mock_sleep.call_count == 1 + # Assert sleep was called with the value from Retry-After header + mock_sleep.assert_called_once_with(10.0) + + @pytest.mark.asyncio + @respx.mock + async def test_retry_after_header_http_date(self, base_url: str, mocker: MockerFixture): + """Test respecting Retry-After header with an HTTP-date value.""" + retry_config = HttpxRetry( + max_retries=1, respect_retry_after_header=True, backoff_factor=100) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + # Calculate a future time and format as HTTP-date + retry_delay_seconds = 60 + time_at_request = time.time() + retry_time = time_at_request + retry_delay_seconds + http_date = email.utils.formatdate(retry_time) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(503, text="Maintenance", headers={'Retry-After': http_date}), + httpx.Response(200, text="OK"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + # Patch time.time() within the test context to control the baseline for date calculation + # Set the mock time to be *just before* the Retry-After time + mocker.patch('time.time', return_value=time_at_request) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 2 + assert mock_sleep.call_count == 1 + # Check that sleep was called with approximately the correct delay + # Allow for small floating point inaccuracies + mock_sleep.assert_called_once() + args, _ = mock_sleep.call_args + assert args[0] == pytest.approx(retry_delay_seconds, abs=2) + + @pytest.mark.asyncio + @respx.mock + async def test_retry_after_ignored_when_disabled(self, base_url: str, mocker: MockerFixture): + """Test Retry-After header is ignored if `respect_retry_after_header` is `False`.""" + retry_config = HttpxRetry( + max_retries=3, respect_retry_after_header=False, status_forcelist=[429], + backoff_factor=0.5, backoff_max=10) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx.Response(429, text="Too Many Requests", headers={'Retry-After': '60'}), + httpx.Response(200, text="OK"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 4 + assert mock_sleep.call_count == 3 + + # Assert sleep was called with the calculated backoff times: + # After first attempt: delay = 0 + # After retry 1 (attempt 2): delay = 0.5 * (2**(2-1)) = 0.5 * 2 = 1.0 + # After retry 2 (attempt 3): delay = 0.5 * (2**(3-1)) = 0.5 * 4 = 2.0 + expected_sleeps = [call(0), call(1.0), call(2.0)] + mock_sleep.assert_has_calls(expected_sleeps) + + @pytest.mark.asyncio + @respx.mock + async def test_retry_after_header_missing_backoff_fallback( + self, base_url: str, mocker: MockerFixture + ): + """Test Retry-After header is ignored if `respect_retry_after_header`is `True` but header is + not set.""" + retry_config = HttpxRetry( + max_retries=3, respect_retry_after_header=True, status_forcelist=[429], + backoff_factor=0.5, backoff_max=10) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(429, text="Too Many Requests"), + httpx.Response(429, text="Too Many Requests"), + httpx.Response(429, text="Too Many Requests"), + httpx.Response(200, text="OK"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 4 + assert mock_sleep.call_count == 3 + + # Assert sleep was called with the calculated backoff times: + # After first attempt: delay = 0 + # After retry 1 (attempt 2): delay = 0.5 * (2**(2-1)) = 0.5 * 2 = 1.0 + # After retry 2 (attempt 3): delay = 0.5 * (2**(3-1)) = 0.5 * 4 = 2.0 + expected_sleeps = [call(0), call(1.0), call(2.0)] + mock_sleep.assert_has_calls(expected_sleeps) + + @pytest.mark.asyncio + @respx.mock + async def test_exponential_backoff(self, base_url: str, mocker: MockerFixture): + """Test that sleep time increases exponentially with `backoff_factor`.""" + # status=3 allows 3 retries (attempts 2, 3, 4) + retry_config = HttpxRetry( + max_retries=3, status_forcelist=[500], backoff_factor=0.1, backoff_max=10.0) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(500, text="Fail 1"), + httpx.Response(500, text="Fail 2"), + httpx.Response(500, text="Fail 3"), + httpx.Response(200, text="Success"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 4 + assert mock_sleep.call_count == 3 + + # Check expected backoff times: + # After first attempt: delay = 0 + # After retry 1 (attempt 2): delay = 0.1 * (2**(2-1)) = 0.1 * 2 = 0.2 + # After retry 2 (attempt 3): delay = 0.1 * (2**(3-1)) = 0.1 * 4 = 0.4 + expected_sleeps = [call(0), call(0.2), call(0.4)] + mock_sleep.assert_has_calls(expected_sleeps) + + @pytest.mark.asyncio + @respx.mock + async def test_backoff_max(self, base_url: str, mocker: MockerFixture): + """Test that backoff time respects `backoff_max`.""" + # status=4 allows 4 retries. backoff_factor=1 causes rapid increase. + retry_config = HttpxRetry( + max_retries=4, status_forcelist=[500], backoff_factor=1, backoff_max=3.0) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(500, text="Fail 1"), + httpx.Response(500, text="Fail 2"), + httpx.Response(500, text="Fail 2"), + httpx.Response(500, text="Fail 4"), + httpx.Response(200, text="Success"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 5 + assert mock_sleep.call_count == 4 + + # Check expected backoff times: + # After first attempt: delay = 0 + # After retry 1 (attempt 2): delay = 1*(2**(2-1)) = 2. Clamped by max(0, min(3.0, 2)) = 2.0 + # After retry 2 (attempt 3): delay = 1*(2**(3-1)) = 4. Clamped by max(0, min(3.0, 4)) = 3.0 + # After retry 3 (attempt 4): delay = 1*(2**(4-1)) = 8. Clamped by max(0, min(3.0, 8)) = 3.0 + expected_sleeps = [call(0.0), call(2.0), call(3.0), call(3.0)] + mock_sleep.assert_has_calls(expected_sleeps) + + @pytest.mark.asyncio + @respx.mock + async def test_backoff_jitter(self, base_url: str, mocker: MockerFixture): + """Test that `backoff_jitter` adds randomness within bounds.""" + retry_config = HttpxRetry( + max_retries=3, status_forcelist=[500], backoff_factor=0.2, backoff_jitter=0.1) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + route = respx.post(base_url).mock(side_effect=[ + httpx.Response(500, text="Fail 1"), + httpx.Response(500, text="Fail 2"), + httpx.Response(500, text="Fail 3"), + httpx.Response(200, text="Success"), + ]) + + mock_sleep = mocker.patch('asyncio.sleep', return_value=None) + response = await client.post(base_url) + + assert response.status_code == 200 + assert route.call_count == 4 + assert mock_sleep.call_count == 3 + + # Check expected backoff times are within the expected range [base - jitter, base + jitter] + # After first attempt: delay = 0 + # After retry 1 (attempt 2): delay = 0.2 * (2**(2-1)) = 0.2 * 2 = 0.4 +/- 0.1 + # After retry 2 (attempt 3): delay = 0.2 * (2**(3-1)) = 0.2 * 4 = 0.8 +/- 0.1 + expected_sleeps = [ + call(pytest.approx(0.0, abs=0.1)), + call(pytest.approx(0.4, abs=0.1)), + call(pytest.approx(0.8, abs=0.1)) + ] + mock_sleep.assert_has_calls(expected_sleeps) + + @pytest.mark.asyncio + @respx.mock + async def test_error_not_retryable(self, base_url): + """Test that non-HTTP errors are raised immediately if not retryable.""" + retry_config = HttpxRetry(max_retries=3) + transport = HttpxRetryTransport(retry=retry_config) + client = httpx.AsyncClient(transport=transport) + + # Mock a connection error + route = respx.post(base_url).mock( + side_effect=repeat(httpx.ConnectError("Connection failed"))) + + with pytest.raises(httpx.ConnectError, match="Connection failed"): + await client.post(base_url) + + assert route.call_count == 1 + + +class TestHttpxRetry(): + _TEST_REQUEST = httpx.Request('POST', _TEST_URL) + + def test_httpx_retry_copy(self, base_url): + """Test that `HttpxRetry.copy()` creates a deep copy.""" + original = HttpxRetry(max_retries=5, status_forcelist=[500, 503], backoff_factor=0.5) + original.history.append((base_url, None, None)) # Add something mutable + + copied = original.copy() + + # Assert they are different objects + assert original is not copied + assert original.history is not copied.history + + # Assert values are the same initially + assert copied.retries_left == original.retries_left + assert copied.status_forcelist == original.status_forcelist + assert copied.backoff_factor == original.backoff_factor + assert len(copied.history) == 1 + + # Modify the copy and check original is unchanged + copied.retries_left = 1 + copied.status_forcelist = [404] + copied.history.append((base_url, None, None)) + + assert original.retries_left == 5 + assert original.status_forcelist == [500, 503] + assert len(original.history) == 1 + + def test_parse_retry_after_seconds(self): + retry = HttpxRetry() + assert retry._parse_retry_after('10') == 10.0 + assert retry._parse_retry_after(' 30 ') == 30.0 + + + def test_parse_retry_after_http_date(self, mocker: MockerFixture): + mocker.patch('time.time', return_value=1000.0) + retry = HttpxRetry() + # Date string representing 1015 seconds since epoch + http_date = email.utils.formatdate(1015.0) + # time.time() is mocked to 1000.0, so delay should be 15s + assert retry._parse_retry_after(http_date) == pytest.approx(15.0) + + def test_parse_retry_after_past_http_date(self, mocker: MockerFixture): + """Test that a past date results in 0 seconds.""" + mocker.patch('time.time', return_value=1000.0) + retry = HttpxRetry() + http_date = email.utils.formatdate(990.0) # 10s in the past + assert retry._parse_retry_after(http_date) == 0.0 + + def test_parse_retry_after_invalid_date(self): + retry = HttpxRetry() + with pytest.raises(httpx.RemoteProtocolError, match='Invalid Retry-After header'): + retry._parse_retry_after('Invalid Date Format') + + def test_get_backoff_time_calculation(self): + retry = HttpxRetry( + max_retries=6, status_forcelist=[503], backoff_factor=0.5, backoff_max=10.0) + response = httpx.Response(503) + # No history -> attempt 1 -> no backoff before first request + # Note: get_backoff_time() is typically called *before* the *next* request, + # so history length reflects completed attempts. + assert retry.get_backoff_time() == 0.0 + + # Simulate attempt 1 completed + retry.increment(self._TEST_REQUEST, response) + # History len 1, attempt 2 -> base case 0 + assert retry.get_backoff_time() == pytest.approx(0) + + # Simulate attempt 2 completed + retry.increment(self._TEST_REQUEST, response) + # History len 2, attempt 3 -> 0.5*(2^1) = 1.0 + assert retry.get_backoff_time() == pytest.approx(1.0) + + # Simulate attempt 3 completed + retry.increment(self._TEST_REQUEST, response) + # History len 3, attempt 4 -> 0.5*(2^2) = 2.0 + assert retry.get_backoff_time() == pytest.approx(2.0) + + # Simulate attempt 4 completed + retry.increment(self._TEST_REQUEST, response) + # History len 4, attempt 5 -> 0.5*(2^3) = 4.0 + assert retry.get_backoff_time() == pytest.approx(4.0) + + # Simulate attempt 5 completed + retry.increment(self._TEST_REQUEST, response) + # History len 5, attempt 6 -> 0.5*(2^4) = 8.0 + assert retry.get_backoff_time() == pytest.approx(8.0) + + # Simulate attempt 6 completed + retry.increment(self._TEST_REQUEST, response) + # History len 6, attempt 7 -> 0.5*(2^5) = 16.0 Clamped to 10 + assert retry.get_backoff_time() == pytest.approx(10.0) diff --git a/tests/test_sseclient.py b/tests/test_sseclient.py index 70edcf0d0..2c523e36f 100644 --- a/tests/test_sseclient.py +++ b/tests/test_sseclient.py @@ -25,10 +25,10 @@ class MockSSEClientAdapter(testutils.MockAdapter): def __init__(self, payload, recorder): - super(MockSSEClientAdapter, self).__init__(payload, 200, recorder) + super().__init__(payload, 200, recorder) def send(self, request, **kwargs): - resp = super(MockSSEClientAdapter, self).send(request, **kwargs) + resp = super().send(request, **kwargs) resp.url = request.url resp.status_code = self.status resp.raw = io.BytesIO(self.data.encode()) diff --git a/tests/test_storage.py b/tests/test_storage.py index e15c4e2ab..c874ef640 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -33,7 +33,7 @@ def test_invalid_config(): with pytest.raises(ValueError): storage.bucket() -@pytest.mark.parametrize('name', [None, '', 0, 1, True, False, list(), tuple(), dict()]) +@pytest.mark.parametrize('name', [None, '', 0, 1, True, False, [], tuple(), {}]) def test_invalid_name(name): with pytest.raises(ValueError): storage.bucket(name) diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index f92dd2a83..900faa376 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -15,6 +15,7 @@ """Test cases for the firebase_admin.tenant_mgt module.""" import json +import unittest.mock from urllib import parse import pytest @@ -26,8 +27,10 @@ from firebase_admin import tenant_mgt from firebase_admin import _auth_providers from firebase_admin import _user_mgt +from firebase_admin import _utils from tests import testutils from tests import test_token_gen +from tests.test_token_gen import MOCK_CURRENT_TIME, MOCK_CURRENT_TIME_UTC GET_TENANT_RESPONSE = """{ @@ -104,12 +107,12 @@ LIST_OIDC_PROVIDER_CONFIGS_RESPONSE = testutils.resource('list_oidc_provider_configs.json') LIST_SAML_PROVIDER_CONFIGS_RESPONSE = testutils.resource('list_saml_provider_configs.json') -INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()] -INVALID_BOOLEANS = ['', 1, 0, list(), tuple(), dict()] +INVALID_TENANT_IDS = [None, '', 0, 1, True, False, [], tuple(), {}] +INVALID_BOOLEANS = ['', 1, 0, [], tuple(), {}] USER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v1/projects/mock-project-id' -PROVIDER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' -TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' +PROVIDER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2/projects/mock-project-id' +TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2/projects/mock-project-id' @pytest.fixture(scope='module') @@ -149,7 +152,7 @@ def _instrument_provider_mgt(client, status, payload): class TestTenant: - @pytest.mark.parametrize('data', [None, 'foo', 0, 1, True, False, list(), tuple(), dict()]) + @pytest.mark.parametrize('data', [None, 'foo', 0, 1, True, False, [], tuple(), {}]) def test_invalid_data(self, data): with pytest.raises(ValueError): tenant_mgt.Tenant(data) @@ -194,7 +197,10 @@ def test_get_tenant(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX) + assert req.url == f'{TENANT_MGT_URL_PREFIX}/tenants/tenant-id' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header def test_tenant_not_found(self, tenant_mgt_app): _instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE) @@ -210,7 +216,7 @@ def test_tenant_not_found(self, tenant_mgt_app): class TestCreateTenant: - @pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()]) + @pytest.mark.parametrize('display_name', [True, False, 1, 0, [], tuple(), {}]) def test_invalid_display_name_type(self, display_name, tenant_mgt_app): with pytest.raises(ValueError) as excinfo: tenant_mgt.create_tenant(display_name=display_name, app=tenant_mgt_app) @@ -284,7 +290,10 @@ def _assert_request(self, recorder, body): assert len(recorder) == 1 req = recorder[0] assert req.method == 'POST' - assert req.url == '{0}/tenants'.format(TENANT_MGT_URL_PREFIX) + assert req.url == f'{TENANT_MGT_URL_PREFIX}/tenants' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header got = json.loads(req.body.decode()) assert got == body @@ -297,7 +306,7 @@ def test_invalid_tenant_id(self, tenant_id, tenant_mgt_app): tenant_mgt.update_tenant(tenant_id, display_name='My Tenant', app=tenant_mgt_app) assert str(excinfo.value).startswith('Tenant ID must be a non-empty string') - @pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()]) + @pytest.mark.parametrize('display_name', [True, False, 1, 0, [], tuple(), {}]) def test_invalid_display_name_type(self, display_name, tenant_mgt_app): with pytest.raises(ValueError) as excinfo: tenant_mgt.update_tenant('tenant-id', display_name=display_name, app=tenant_mgt_app) @@ -381,8 +390,10 @@ def _assert_request(self, recorder, body, mask): assert len(recorder) == 1 req = recorder[0] assert req.method == 'PATCH' - assert req.url == '{0}/tenants/tenant-id?updateMask={1}'.format( - TENANT_MGT_URL_PREFIX, ','.join(mask)) + assert req.url == f'{TENANT_MGT_URL_PREFIX}/tenants/tenant-id?updateMask={",".join(mask)}' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header got = json.loads(req.body.decode()) assert got == body @@ -402,7 +413,10 @@ def test_delete_tenant(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'DELETE' - assert req.url == '{0}/tenants/tenant-id'.format(TENANT_MGT_URL_PREFIX) + assert req.url == f'{TENANT_MGT_URL_PREFIX}/tenants/tenant-id' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header def test_tenant_not_found(self, tenant_mgt_app): _instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE) @@ -418,12 +432,12 @@ def test_tenant_not_found(self, tenant_mgt_app): class TestListTenants: - @pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 101, False]) + @pytest.mark.parametrize('arg', [None, 'foo', [], {}, 0, -1, 101, False]) def test_invalid_max_results(self, tenant_mgt_app, arg): with pytest.raises(ValueError): tenant_mgt.list_tenants(max_results=arg, app=tenant_mgt_app) - @pytest.mark.parametrize('arg', ['', list(), dict(), 0, -1, True, False]) + @pytest.mark.parametrize('arg', ['', [], {}, 0, -1, True, False]) def test_invalid_page_token(self, tenant_mgt_app, arg): with pytest.raises(ValueError): tenant_mgt.list_tenants(page_token=arg, app=tenant_mgt_app) @@ -435,7 +449,7 @@ def test_list_single_page(self, tenant_mgt_app): assert page.next_page_token == '' assert page.has_next_page is False assert page.get_next_page() is None - tenants = [tenant for tenant in page.iterate_all()] + tenants = list(page.iterate_all()) assert len(tenants) == 2 self._assert_request(recorder) @@ -465,7 +479,7 @@ def test_list_tenants_paged_iteration(self, tenant_mgt_app): iterator = page.iterate_all() for index in range(3): tenant = next(iterator) - assert tenant.tenant_id == 'tenant{0}'.format(index) + assert tenant.tenant_id == f'tenant{index}' self._assert_request(recorder) # Page 2 (also the last page) @@ -499,7 +513,7 @@ def test_list_tenants_stop_iteration(self, tenant_mgt_app): _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE) page = tenant_mgt.list_tenants(app=tenant_mgt_app) iterator = page.iterate_all() - tenants = [tenant for tenant in iterator] + tenants = list(iterator) assert len(tenants) == 2 with pytest.raises(StopIteration): @@ -511,7 +525,7 @@ def test_list_tenants_no_tenants_response(self, tenant_mgt_app): _instrument_tenant_mgt(tenant_mgt_app, 200, json.dumps(response)) page = tenant_mgt.list_tenants(app=tenant_mgt_app) assert len(page.tenants) == 0 - tenants = [tenant for tenant in page.iterate_all()] + tenants = list(page.iterate_all()) assert len(tenants) == 0 def test_list_tenants_with_max_results(self, tenant_mgt_app): @@ -536,7 +550,7 @@ def _assert_tenants_page(self, page): assert isinstance(page, tenant_mgt.ListTenantsPage) assert len(page.tenants) == 2 for idx, tenant in enumerate(page.tenants): - _assert_tenant(tenant, 'tenant{0}'.format(idx)) + _assert_tenant(tenant, f'tenant{idx}') def _assert_request(self, recorder, expected=None): if expected is None: @@ -545,6 +559,9 @@ def _assert_request(self, recorder, expected=None): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header request = dict(parse.parse_qsl(parse.urlsplit(req.url).query)) assert request == expected @@ -653,8 +670,7 @@ def test_revoke_refresh_tokens(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'POST' - assert req.url == '{0}/tenants/tenant-id/accounts:update'.format( - USER_MGT_URL_PREFIX) + assert req.url == f'{USER_MGT_URL_PREFIX}/tenants/tenant-id/accounts:update' body = json.loads(req.body.decode()) assert body['localId'] == 'testuser' assert 'validSince' in body @@ -675,8 +691,9 @@ def test_list_users(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}/tenants/tenant-id/accounts:batchGet?maxResults=1000'.format( - USER_MGT_URL_PREFIX) + assert req.url == ( + f'{USER_MGT_URL_PREFIX}/tenants/tenant-id/accounts:batchGet?maxResults=1000' + ) def test_import_users(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -747,8 +764,9 @@ def test_get_oidc_provider_config(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}/tenants/tenant-id/oauthIdpConfigs/oidc.provider'.format( - PROVIDER_MGT_URL_PREFIX) + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/oauthIdpConfigs/oidc.provider' + ) def test_create_oidc_provider_config(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -773,7 +791,7 @@ def test_update_oidc_provider_config(self, tenant_mgt_app): self._assert_oidc_provider_config(provider_config) mask = ['clientId', 'displayName', 'enabled', 'issuer'] - url = '/oauthIdpConfigs/oidc.provider?updateMask={0}'.format(','.join(mask)) + url = f'/oauthIdpConfigs/oidc.provider?updateMask={",".join(mask)}' self._assert_request( recorder, url, OIDC_PROVIDER_CONFIG_REQUEST, method='PATCH', prefix=PROVIDER_MGT_URL_PREFIX) @@ -787,8 +805,9 @@ def test_delete_oidc_provider_config(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'DELETE' - assert req.url == '{0}/tenants/tenant-id/oauthIdpConfigs/oidc.provider'.format( - PROVIDER_MGT_URL_PREFIX) + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/oauthIdpConfigs/oidc.provider' + ) def test_list_oidc_provider_configs(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -801,7 +820,7 @@ def test_list_oidc_provider_configs(self, tenant_mgt_app): assert len(page.provider_configs) == 2 for provider_config in page.provider_configs: self._assert_oidc_provider_config( - provider_config, want_id='oidc.provider{0}'.format(index)) + provider_config, want_id=f'oidc.provider{index}') index += 1 assert page.next_page_token == '' @@ -813,8 +832,9 @@ def test_list_oidc_provider_configs(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}{1}'.format( - PROVIDER_MGT_URL_PREFIX, '/tenants/tenant-id/oauthIdpConfigs?pageSize=100') + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/oauthIdpConfigs?pageSize=100' + ) def test_get_saml_provider_config(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -826,8 +846,9 @@ def test_get_saml_provider_config(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}/tenants/tenant-id/inboundSamlConfigs/saml.provider'.format( - PROVIDER_MGT_URL_PREFIX) + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/inboundSamlConfigs/saml.provider' + ) def test_create_saml_provider_config(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -859,7 +880,7 @@ def test_update_saml_provider_config(self, tenant_mgt_app): 'displayName', 'enabled', 'idpConfig.idpCertificates', 'idpConfig.idpEntityId', 'idpConfig.ssoUrl', 'spConfig.callbackUri', 'spConfig.spEntityId', ] - url = '/inboundSamlConfigs/saml.provider?updateMask={0}'.format(','.join(mask)) + url = f'/inboundSamlConfigs/saml.provider?updateMask={",".join(mask)}' self._assert_request( recorder, url, SAML_PROVIDER_CONFIG_REQUEST, method='PATCH', prefix=PROVIDER_MGT_URL_PREFIX) @@ -873,8 +894,9 @@ def test_delete_saml_provider_config(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'DELETE' - assert req.url == '{0}/tenants/tenant-id/inboundSamlConfigs/saml.provider'.format( - PROVIDER_MGT_URL_PREFIX) + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/inboundSamlConfigs/saml.provider' + ) def test_list_saml_provider_configs(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -887,7 +909,7 @@ def test_list_saml_provider_configs(self, tenant_mgt_app): assert len(page.provider_configs) == 2 for provider_config in page.provider_configs: self._assert_saml_provider_config( - provider_config, want_id='saml.provider{0}'.format(index)) + provider_config, want_id=f'saml.provider{index}') index += 1 assert page.next_page_token == '' @@ -899,8 +921,9 @@ def test_list_saml_provider_configs(self, tenant_mgt_app): assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' - assert req.url == '{0}{1}'.format( - PROVIDER_MGT_URL_PREFIX, '/tenants/tenant-id/inboundSamlConfigs?pageSize=100') + assert req.url == ( + f'{PROVIDER_MGT_URL_PREFIX}/tenants/tenant-id/inboundSamlConfigs?pageSize=100' + ) def test_tenant_not_found(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app) @@ -919,7 +942,10 @@ def _assert_request( assert len(recorder) == 1 req = recorder[0] assert req.method == method - assert req.url == '{0}/tenants/tenant-id{1}'.format(prefix, want_url) + assert req.url == f'{prefix}/tenants/tenant-id{want_url}' + assert req.headers['X-Client-Version'] == f'Python/Admin/{firebase_admin.__version__}' + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert req.headers['x-goog-api-client'] == expected_metrics_header body = json.loads(req.body.decode()) assert body == want_body @@ -945,6 +971,17 @@ def _assert_saml_provider_config(self, provider_config, want_id='saml.provider') class TestVerifyIdToken: + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.mock_time = self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.mock_utcnow = self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + def test_valid_token(self, tenant_mgt_app): client = tenant_mgt.auth_for_tenant('test-tenant', app=tenant_mgt_app) client._token_verifier.request = test_token_gen.MOCK_REQUEST @@ -978,6 +1015,17 @@ def tenant_aware_custom_token_app(): class TestCreateCustomToken: + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.mock_time = self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.mock_utcnow = self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + def test_custom_token(self, tenant_aware_custom_token_app): client = tenant_mgt.auth_for_tenant('test-tenant', app=tenant_aware_custom_token_app) diff --git a/tests/test_token_gen.py b/tests/test_token_gen.py index f88c87ff4..384bc22c3 100644 --- a/tests/test_token_gen.py +++ b/tests/test_token_gen.py @@ -19,6 +19,7 @@ import json import os import time +import unittest.mock from google.auth import crypt from google.auth import jwt @@ -31,10 +32,14 @@ from firebase_admin import auth from firebase_admin import credentials from firebase_admin import exceptions +from firebase_admin import _http_client from firebase_admin import _token_gen from tests import testutils +MOCK_CURRENT_TIME = 1500000000 +MOCK_CURRENT_TIME_UTC = datetime.datetime.fromtimestamp( + MOCK_CURRENT_TIME, tz=datetime.timezone.utc) MOCK_UID = 'user1' MOCK_CREDENTIAL = credentials.Certificate( testutils.resource_filename('service_account.json')) @@ -43,8 +48,8 @@ MOCK_SERVICE_ACCOUNT_EMAIL = MOCK_CREDENTIAL.service_account_email MOCK_REQUEST = testutils.MockRequest(200, MOCK_PUBLIC_CERTS) -INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] -INVALID_BOOLS = [None, '', 'foo', 0, 1, list(), tuple(), dict()] +INVALID_STRINGS = [None, '', 0, 1, True, False, [], tuple(), {}] +INVALID_BOOLS = [None, '', 'foo', 0, 1, [], tuple(), {}] INVALID_JWT_ARGS = { 'NoneToken': None, 'EmptyToken': '', @@ -55,6 +60,14 @@ 'NonEmptyDictToken': {'a': 1}, } +ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1' +EMULATOR_HOST_ENV_VAR = 'FIREBASE_AUTH_EMULATOR_HOST' +AUTH_EMULATOR_HOST = 'localhost:9099' +EMULATED_ID_TOOLKIT_URL = f'http://{AUTH_EMULATOR_HOST}/identitytoolkit.googleapis.com/v1' +TOKEN_MGT_URLS = { + 'ID_TOOLKIT': ID_TOOLKIT_URL, +} + # Fixture for mocking a HTTP server httpserver = plugin.httpserver @@ -66,37 +79,47 @@ def _merge_jwt_claims(defaults, overrides): del defaults[key] return defaults + def verify_custom_token(custom_token, expected_claims, tenant_id=None): assert isinstance(custom_token, bytes) - token = google.oauth2.id_token.verify_token( - custom_token, - testutils.MockRequest(200, MOCK_PUBLIC_CERTS), - _token_gen.FIREBASE_AUDIENCE) + expected_email = MOCK_SERVICE_ACCOUNT_EMAIL + header = jwt.decode_header(custom_token) + assert header.get('typ') == 'JWT' + if _is_emulated(): + assert header.get('alg') == 'none' + assert custom_token.split(b'.')[2] == b'' + expected_email = _token_gen.AUTH_EMULATOR_EMAIL + token = jwt.decode(custom_token, verify=False) + else: + assert header.get('alg') == 'RS256' + token = google.oauth2.id_token.verify_token( + custom_token, + testutils.MockRequest(200, MOCK_PUBLIC_CERTS), + _token_gen.FIREBASE_AUDIENCE) + assert token['uid'] == MOCK_UID - assert token['iss'] == MOCK_SERVICE_ACCOUNT_EMAIL - assert token['sub'] == MOCK_SERVICE_ACCOUNT_EMAIL + assert token['iss'] == expected_email + assert token['sub'] == expected_email if tenant_id is None: assert 'tenant_id' not in token else: assert token['tenant_id'] == tenant_id - header = jwt.decode_header(custom_token) - assert header.get('typ') == 'JWT' - assert header.get('alg') == 'RS256' if expected_claims: for key, value in expected_claims.items(): assert value == token['claims'][key] -def _get_id_token(payload_overrides=None, header_overrides=None): +def _get_id_token(payload_overrides=None, header_overrides=None, current_time=MOCK_CURRENT_TIME): signer = crypt.RSASigner.from_string(MOCK_PRIVATE_KEY) headers = { 'kid': 'mock-key-id-1' } + now = int(current_time if current_time is not None else time.time()) payload = { 'aud': MOCK_CREDENTIAL.project_id, 'iss': 'https://securetoken.google.com/' + MOCK_CREDENTIAL.project_id, - 'iat': int(time.time()) - 100, - 'exp': int(time.time()) + 3600, + 'iat': now - 100, + 'exp': now + 3600, 'sub': '1234567890', 'admin': True, 'firebase': { @@ -109,19 +132,21 @@ def _get_id_token(payload_overrides=None, header_overrides=None): payload = _merge_jwt_claims(payload, payload_overrides) return jwt.encode(signer, payload, header=headers) -def _get_session_cookie(payload_overrides=None, header_overrides=None): +def _get_session_cookie( + payload_overrides=None, header_overrides=None, current_time=MOCK_CURRENT_TIME): payload_overrides = payload_overrides or {} if 'iss' not in payload_overrides: - payload_overrides['iss'] = 'https://session.firebase.google.com/{0}'.format( - MOCK_CREDENTIAL.project_id) - return _get_id_token(payload_overrides, header_overrides) + payload_overrides['iss'] = ( + f'https://session.firebase.google.com/{MOCK_CREDENTIAL.project_id}' + ) + return _get_id_token(payload_overrides, header_overrides, current_time=current_time) def _instrument_user_manager(app, status, payload): client = auth._get_client(app) user_manager = client._user_manager recorder = [] user_manager.http_client.session.mount( - _token_gen.TokenGenerator.ID_TOOLKIT_URL, + TOKEN_MGT_URLS['ID_TOOLKIT'], testutils.MockAdapter(payload, status, recorder)) return user_manager, recorder @@ -133,23 +158,41 @@ def _overwrite_iam_request(app, request): client = auth._get_client(app) client._token_generator.request = request -@pytest.fixture(scope='module') -def auth_app(): + +def _is_emulated(): + emulator_host = os.getenv(EMULATOR_HOST_ENV_VAR, '') + return emulator_host and '//' not in emulator_host + + +# These fixtures are set to the default function scope as the emulator environment variable bleeds +# over when in module scope. +@pytest.fixture(params=[{'emulated': False}, {'emulated': True}]) +def auth_app(request): """Returns an App initialized with a mock service account credential. This can be used in any scenario where the private key is required. Use user_mgt_app for everything else. """ + monkeypatch = testutils.new_monkeypatch() + if request.param['emulated']: + monkeypatch.setenv(EMULATOR_HOST_ENV_VAR, AUTH_EMULATOR_HOST) + monkeypatch.setitem(TOKEN_MGT_URLS, 'ID_TOOLKIT', EMULATED_ID_TOOLKIT_URL) app = firebase_admin.initialize_app(MOCK_CREDENTIAL, name='tokenGen') yield app firebase_admin.delete_app(app) - -@pytest.fixture(scope='module') -def user_mgt_app(): + monkeypatch.undo() + +@pytest.fixture(params=[{'emulated': False}, {'emulated': True}]) +def user_mgt_app(request): + monkeypatch = testutils.new_monkeypatch() + if request.param['emulated']: + monkeypatch.setenv(EMULATOR_HOST_ENV_VAR, AUTH_EMULATOR_HOST) + monkeypatch.setitem(TOKEN_MGT_URLS, 'ID_TOOLKIT', EMULATED_ID_TOOLKIT_URL) app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt', options={'projectId': 'mock-project-id'}) yield app firebase_admin.delete_app(app) + monkeypatch.undo() @pytest.fixture def env_var_app(request): @@ -169,7 +212,20 @@ def env_var_app(request): @pytest.fixture(scope='module') def revoked_tokens(): mock_user = json.loads(testutils.resource('get_user.json')) - mock_user['users'][0]['validSince'] = str(int(time.time())+100) + mock_user['users'][0]['validSince'] = str(MOCK_CURRENT_TIME + 100) + return json.dumps(mock_user) + +@pytest.fixture(scope='module') +def user_disabled(): + mock_user = json.loads(testutils.resource('get_user.json')) + mock_user['users'][0]['disabled'] = True + return json.dumps(mock_user) + +@pytest.fixture(scope='module') +def user_disabled_and_revoked(): + mock_user = json.loads(testutils.resource('get_user.json')) + mock_user['users'][0]['disabled'] = True + mock_user['users'][0]['validSince'] = str(MOCK_CURRENT_TIME + 100) return json.dumps(mock_user) @@ -212,6 +268,12 @@ def test_invalid_params(self, auth_app, values): auth.create_custom_token(user, claims, app=auth_app) def test_noncert_credential(self, user_mgt_app): + if _is_emulated(): + # Should work fine with the emulator, so do a condensed version of + # test_sign_with_iam below. + custom_token = auth.create_custom_token(MOCK_UID, app=user_mgt_app).decode() + self._verify_signer(custom_token, _token_gen.AUTH_EMULATOR_EMAIL) + return with pytest.raises(ValueError): auth.create_custom_token(MOCK_UID, app=user_mgt_app) @@ -221,7 +283,7 @@ def test_sign_with_iam(self): testutils.MockCredential(), name='iam-signer-app', options=options) try: signature = base64.b64encode(b'test').decode() - iam_resp = '{{"signature": "{0}"}}'.format(signature) + iam_resp = json.dumps({'signedBlob': signature}) _overwrite_iam_request(app, testutils.MockRequest(200, iam_resp)) custom_token = auth.create_custom_token(MOCK_UID, app=app).decode() assert custom_token.endswith('.' + signature.rstrip('=')) @@ -258,8 +320,7 @@ def test_sign_with_discovered_service_account(self): # Now invoke the IAM signer. signature = base64.b64encode(b'test').decode() - request.response = testutils.MockResponse( - 200, '{{"signature": "{0}"}}'.format(signature)) + request.response = testutils.MockResponse(200, json.dumps({'signedBlob': signature})) custom_token = auth.create_custom_token(MOCK_UID, app=app).decode() assert custom_token.endswith('.' + signature.rstrip('=')) self._verify_signer(custom_token, 'discovered-service-account') @@ -286,20 +347,20 @@ def test_sign_with_discovery_failure(self): def _verify_signer(self, token, signer): segments = token.split('.') assert len(segments) == 3 - body = json.loads(base64.b64decode(segments[1]).decode()) + body = jwt.decode(token, verify=False) assert body['iss'] == signer assert body['sub'] == signer class TestCreateSessionCookie: - @pytest.mark.parametrize('id_token', [None, '', 0, 1, True, False, list(), dict(), tuple()]) + @pytest.mark.parametrize('id_token', [None, '', 0, 1, True, False, [], {}, tuple()]) def test_invalid_id_token(self, user_mgt_app, id_token): with pytest.raises(ValueError): auth.create_session_cookie(id_token, expires_in=3600, app=user_mgt_app) @pytest.mark.parametrize('expires_in', [ - None, '', True, False, list(), dict(), tuple(), + None, '', True, False, [], {}, tuple(), _token_gen.MIN_SESSION_COOKIE_DURATION_SECONDS - 1, _token_gen.MAX_SESSION_COOKIE_DURATION_SECONDS + 1, ]) @@ -365,6 +426,17 @@ def test_unexpected_response(self, user_mgt_app): class TestVerifyIdToken: + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + valid_tokens = { 'BinaryToken': TEST_ID_TOKEN, 'TextToken': TEST_ID_TOKEN.decode('utf-8'), @@ -380,22 +452,37 @@ class TestVerifyIdToken: 'EmptySubject': _get_id_token({'sub': ''}), 'IntSubject': _get_id_token({'sub': 10}), 'LongStrSubject': _get_id_token({'sub': 'a' * 129}), - 'FutureToken': _get_id_token({'iat': int(time.time()) + 1000}), + 'FutureToken': _get_id_token({'iat': MOCK_CURRENT_TIME + 1000}), 'ExpiredToken': _get_id_token({ - 'iat': int(time.time()) - 10000, - 'exp': int(time.time()) - 3600 + 'iat': MOCK_CURRENT_TIME - 10000, + 'exp': MOCK_CURRENT_TIME - 3600 + }), + 'ExpiredTokenShort': _get_id_token({ + 'iat': MOCK_CURRENT_TIME - 10000, + 'exp': MOCK_CURRENT_TIME - 30 }), 'BadFormatToken': 'foobar' } - @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) - def test_valid_token(self, user_mgt_app, id_token): - _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) - claims = auth.verify_id_token(id_token, app=user_mgt_app) + tokens_accepted_in_emulator = [ + 'NoKid', + 'WrongKid', + 'FutureToken', + 'ExpiredToken', + 'ExpiredTokenShort', + ] + + def _assert_valid_token(self, id_token, app): + claims = auth.verify_id_token(id_token, app=app) assert claims['admin'] is True assert claims['uid'] == claims['sub'] assert claims['firebase']['sign_in_provider'] == 'provider' + @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) + def test_valid_token(self, user_mgt_app, id_token): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + self._assert_valid_token(id_token, app=user_mgt_app) + def test_valid_token_with_tenant(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) claims = auth.verify_id_token(TEST_ID_TOKEN_WITH_TENANT, app=user_mgt_app) @@ -419,6 +506,23 @@ def test_revoked_token_check_revoked(self, user_mgt_app, revoked_tokens, id_toke auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) assert str(excinfo.value) == 'The Firebase ID token has been revoked.' + @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) + def test_disabled_user_check_revoked(self, user_mgt_app, user_disabled, id_token): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled) + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + + @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) + def test_check_disabled_before_revoked( + self, user_mgt_app, user_disabled_and_revoked, id_token): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled_and_revoked) + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + @pytest.mark.parametrize('arg', INVALID_BOOLS) def test_invalid_check_revoked(self, user_mgt_app, arg): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) @@ -433,6 +537,14 @@ def test_revoked_token_do_not_check_revoked(self, user_mgt_app, revoked_tokens, assert claims['admin'] is True assert claims['uid'] == claims['sub'] + @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) + def test_disabled_user_do_not_check_revoked(self, user_mgt_app, user_disabled, id_token): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled) + claims = auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=False) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + @pytest.mark.parametrize('id_token', INVALID_JWT_ARGS.values(), ids=list(INVALID_JWT_ARGS)) def test_invalid_arg(self, user_mgt_app, id_token): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) @@ -440,8 +552,12 @@ def test_invalid_arg(self, user_mgt_app, id_token): auth.verify_id_token(id_token, app=user_mgt_app) assert 'Illegal ID token provided' in str(excinfo.value) - @pytest.mark.parametrize('id_token', invalid_tokens.values(), ids=list(invalid_tokens)) - def test_invalid_token(self, user_mgt_app, id_token): + @pytest.mark.parametrize('id_token_key', list(invalid_tokens)) + def test_invalid_token(self, user_mgt_app, id_token_key): + id_token = self.invalid_tokens[id_token_key] + if _is_emulated() and id_token_key in self.tokens_accepted_in_emulator: + self._assert_valid_token(id_token, user_mgt_app) + return _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) with pytest.raises(auth.InvalidIdTokenError) as excinfo: auth.verify_id_token(id_token, app=user_mgt_app) @@ -451,6 +567,9 @@ def test_invalid_token(self, user_mgt_app, id_token): def test_expired_token(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) id_token = self.invalid_tokens['ExpiredToken'] + if _is_emulated(): + self._assert_valid_token(id_token, user_mgt_app) + return with pytest.raises(auth.ExpiredIdTokenError) as excinfo: auth.verify_id_token(id_token, app=user_mgt_app) assert isinstance(excinfo.value, auth.InvalidIdTokenError) @@ -458,6 +577,20 @@ def test_expired_token(self, user_mgt_app): assert excinfo.value.cause is not None assert excinfo.value.http_response is None + def test_expired_token_with_tolerance(self, user_mgt_app): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + id_token = self.invalid_tokens['ExpiredTokenShort'] + if _is_emulated(): + self._assert_valid_token(id_token, user_mgt_app) + return + claims = auth.verify_id_token(id_token, app=user_mgt_app, + clock_skew_seconds=60) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + with pytest.raises(auth.ExpiredIdTokenError): + auth.verify_id_token(id_token, app=user_mgt_app, + clock_skew_seconds=20) + def test_project_id_option(self): app = firebase_admin.initialize_app( testutils.MockCredential(), options={'projectId': 'mock-project-id'}, name='myApp') @@ -488,6 +621,10 @@ def test_custom_token(self, auth_app): def test_certificate_request_failure(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, testutils.MockRequest(404, 'not found')) + if _is_emulated(): + # Shouldn't fetch certificates in emulator mode. + self._assert_valid_token(TEST_ID_TOKEN, app=user_mgt_app) + return with pytest.raises(auth.CertificateFetchError) as excinfo: auth.verify_id_token(TEST_ID_TOKEN, app=user_mgt_app) assert 'Could not fetch certificates' in str(excinfo.value) @@ -498,6 +635,17 @@ def test_certificate_request_failure(self, user_mgt_app): class TestVerifySessionCookie: + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + valid_cookies = { 'BinaryCookie': TEST_SESSION_COOKIE, 'TextCookie': TEST_SESSION_COOKIE.decode('utf-8'), @@ -513,29 +661,42 @@ class TestVerifySessionCookie: 'EmptySubject': _get_session_cookie({'sub': ''}), 'IntSubject': _get_session_cookie({'sub': 10}), 'LongStrSubject': _get_session_cookie({'sub': 'a' * 129}), - 'FutureCookie': _get_session_cookie({'iat': int(time.time()) + 1000}), + 'FutureCookie': _get_session_cookie({'iat': MOCK_CURRENT_TIME + 1000}), 'ExpiredCookie': _get_session_cookie({ - 'iat': int(time.time()) - 10000, - 'exp': int(time.time()) - 3600 + 'iat': MOCK_CURRENT_TIME - 10000, + 'exp': MOCK_CURRENT_TIME - 3600 + }), + 'ExpiredCookieShort': _get_session_cookie({ + 'iat': MOCK_CURRENT_TIME - 10000, + 'exp': MOCK_CURRENT_TIME - 30 }), 'BadFormatCookie': 'foobar', 'IDToken': TEST_ID_TOKEN, } + cookies_accepted_in_emulator = [ + 'NoKid', + 'WrongKid', + 'FutureCookie', + 'ExpiredCookie', + 'ExpiredCookieShort', + ] + + def _assert_valid_cookie(self, cookie, app, check_revoked=False): + claims = auth.verify_session_cookie(cookie, app=app, check_revoked=check_revoked) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) def test_valid_cookie(self, user_mgt_app, cookie): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) - claims = auth.verify_session_cookie(cookie, app=user_mgt_app) - assert claims['admin'] is True - assert claims['uid'] == claims['sub'] + self._assert_valid_cookie(cookie, user_mgt_app) @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) def test_valid_cookie_check_revoked(self, user_mgt_app, cookie): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) _instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_RESPONSE) - claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) - assert claims['admin'] is True - assert claims['uid'] == claims['sub'] + self._assert_valid_cookie(cookie, app=user_mgt_app, check_revoked=True) @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) def test_revoked_cookie_check_revoked(self, user_mgt_app, revoked_tokens, cookie): @@ -549,9 +710,30 @@ def test_revoked_cookie_check_revoked(self, user_mgt_app, revoked_tokens, cookie def test_revoked_cookie_does_not_check_revoked(self, user_mgt_app, revoked_tokens, cookie): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) _instrument_user_manager(user_mgt_app, 200, revoked_tokens) - claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False) - assert claims['admin'] is True - assert claims['uid'] == claims['sub'] + self._assert_valid_cookie(cookie, app=user_mgt_app, check_revoked=False) + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_disabled_user_check_revoked(self, user_mgt_app, user_disabled, cookie): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled) + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_check_disabled_before_revoked( + self, user_mgt_app, user_disabled_and_revoked, cookie): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled_and_revoked) + with pytest.raises(auth.UserDisabledError) as excinfo: + auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) + assert str(excinfo.value) == 'The user record is disabled.' + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_disabled_user_does_not_check_revoked(self, user_mgt_app, user_disabled, cookie): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + _instrument_user_manager(user_mgt_app, 200, user_disabled) + self._assert_valid_cookie(cookie, app=user_mgt_app, check_revoked=False) @pytest.mark.parametrize('cookie', INVALID_JWT_ARGS.values(), ids=list(INVALID_JWT_ARGS)) def test_invalid_args(self, user_mgt_app, cookie): @@ -560,8 +742,12 @@ def test_invalid_args(self, user_mgt_app, cookie): auth.verify_session_cookie(cookie, app=user_mgt_app) assert 'Illegal session cookie provided' in str(excinfo.value) - @pytest.mark.parametrize('cookie', invalid_cookies.values(), ids=list(invalid_cookies)) - def test_invalid_cookie(self, user_mgt_app, cookie): + @pytest.mark.parametrize('cookie_key', list(invalid_cookies)) + def test_invalid_cookie(self, user_mgt_app, cookie_key): + cookie = self.invalid_cookies[cookie_key] + if _is_emulated() and cookie_key in self.cookies_accepted_in_emulator: + self._assert_valid_cookie(cookie, user_mgt_app) + return _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) with pytest.raises(auth.InvalidSessionCookieError) as excinfo: auth.verify_session_cookie(cookie, app=user_mgt_app) @@ -571,6 +757,9 @@ def test_invalid_cookie(self, user_mgt_app, cookie): def test_expired_cookie(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) cookie = self.invalid_cookies['ExpiredCookie'] + if _is_emulated(): + self._assert_valid_cookie(cookie, user_mgt_app) + return with pytest.raises(auth.ExpiredSessionCookieError) as excinfo: auth.verify_session_cookie(cookie, app=user_mgt_app) assert isinstance(excinfo.value, auth.InvalidSessionCookieError) @@ -578,6 +767,20 @@ def test_expired_cookie(self, user_mgt_app): assert excinfo.value.cause is not None assert excinfo.value.http_response is None + def test_expired_cookie_with_tolerance(self, user_mgt_app): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + cookie = self.invalid_cookies['ExpiredCookieShort'] + if _is_emulated(): + self._assert_valid_cookie(cookie, user_mgt_app) + return + claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False, + clock_skew_seconds=59) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + with pytest.raises(auth.ExpiredSessionCookieError): + auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False, + clock_skew_seconds=29) + def test_project_id_option(self): app = firebase_admin.initialize_app( testutils.MockCredential(), options={'projectId': 'mock-project-id'}, name='myApp') @@ -603,6 +806,10 @@ def test_custom_token(self, auth_app): def test_certificate_request_failure(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, testutils.MockRequest(404, 'not found')) + if _is_emulated(): + # Shouldn't fetch certificates in emulator mode. + auth.verify_session_cookie(TEST_SESSION_COOKIE, app=user_mgt_app) + return with pytest.raises(auth.CertificateFetchError) as excinfo: auth.verify_session_cookie(TEST_SESSION_COOKIE, app=user_mgt_app) assert 'Could not fetch certificates' in str(excinfo.value) @@ -613,15 +820,86 @@ def test_certificate_request_failure(self, user_mgt_app): class TestCertificateCaching: + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + def test_certificate_caching(self, user_mgt_app, httpserver): httpserver.serve_content(MOCK_PUBLIC_CERTS, 200, headers={'Cache-Control': 'max-age=3600'}) verifier = _token_gen.TokenVerifier(user_mgt_app) verifier.cookie_verifier.cert_url = httpserver.url verifier.id_token_verifier.cert_url = httpserver.url verifier.verify_session_cookie(TEST_SESSION_COOKIE) - assert len(httpserver.requests) == 1 + # No requests should be made in emulated mode + request_count = 0 if _is_emulated() else 1 + assert len(httpserver.requests) == request_count # Subsequent requests should not fetch certs from the server verifier.verify_session_cookie(TEST_SESSION_COOKIE) - assert len(httpserver.requests) == 1 + assert len(httpserver.requests) == request_count verifier.verify_id_token(TEST_ID_TOKEN) - assert len(httpserver.requests) == 1 + assert len(httpserver.requests) == request_count + + +class TestCertificateFetchTimeout: + + def setup_method(self): + self.time_patch = unittest.mock.patch('time.time', return_value=MOCK_CURRENT_TIME) + self.time_patch.start() + self.utcnow_patch = unittest.mock.patch( + 'google.auth.jwt._helpers.utcnow', return_value=MOCK_CURRENT_TIME_UTC) + self.utcnow_patch.start() + + def teardown_method(self): + self.time_patch.stop() + self.utcnow_patch.stop() + testutils.cleanup_apps() + + timeout_configs = [ + ({'httpTimeout': 4}, 4), + ({'httpTimeout': None}, None), + ({}, _http_client.DEFAULT_TIMEOUT_SECONDS), + ] + + @pytest.mark.parametrize('options, timeout', timeout_configs) + def test_init_request(self, options, timeout): + app = firebase_admin.initialize_app(MOCK_CREDENTIAL, options=options) + + client = auth._get_client(app) + request = client._token_verifier.request + + assert isinstance(request, _token_gen.CertificateFetchRequest) + assert request.timeout_seconds == timeout + + @pytest.mark.parametrize('options, timeout', timeout_configs) + def test_verify_id_token_timeout(self, options, timeout): + app = firebase_admin.initialize_app(MOCK_CREDENTIAL, options=options) + recorder = self._instrument_session(app) + + auth.verify_id_token(TEST_ID_TOKEN) + + assert len(recorder) == 1 + assert recorder[0]._extra_kwargs['timeout'] == timeout + + @pytest.mark.parametrize('options, timeout', timeout_configs) + def test_verify_session_cookie_timeout(self, options, timeout): + app = firebase_admin.initialize_app(MOCK_CREDENTIAL, options=options) + recorder = self._instrument_session(app) + + auth.verify_session_cookie(TEST_SESSION_COOKIE) + + assert len(recorder) == 1 + assert recorder[0]._extra_kwargs['timeout'] == timeout + + def _instrument_session(self, app): + client = auth._get_client(app) + request = client._token_verifier.request + recorder = [] + request.session.mount('https://', testutils.MockAdapter(MOCK_PUBLIC_CERTS, 200, recorder)) + return recorder diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index 79e23373f..4623f5e54 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -28,13 +28,14 @@ from firebase_admin import _http_client from firebase_admin import _user_import from firebase_admin import _user_mgt +from firebase_admin import _utils from tests import testutils -INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] -INVALID_DICTS = [None, 'foo', 0, 1, True, False, list(), tuple()] -INVALID_INTS = [None, 'foo', '1', -1, 1.1, True, False, list(), tuple(), dict()] -INVALID_TIMESTAMPS = ['foo', '1', 0, -1, 1.1, True, False, list(), tuple(), dict()] +INVALID_STRINGS = [None, '', 0, 1, True, False, [], tuple(), {}] +INVALID_DICTS = [None, 'foo', 0, 1, True, False, [], tuple()] +INVALID_INTS = [None, 'foo', '1', -1, 1.1, True, False, [], tuple(), {}] +INVALID_TIMESTAMPS = ['foo', '1', 0, -1, 1.1, True, False, [], tuple(), {}] MOCK_GET_USER_RESPONSE = testutils.resource('get_user.json') MOCK_LIST_USERS_RESPONSE = testutils.resource('list_users.json') @@ -42,7 +43,8 @@ MOCK_ACTION_CODE_DATA = { 'url': 'http://localhost', 'handle_code_in_app': True, - 'dynamic_link_domain': 'http://testly', + 'dynamic_link_domain': 'http://dynamic-link-domain', + 'link_domain': 'http://link-domain', 'ios_bundle_id': 'test.bundle', 'android_package_name': 'test.bundle', 'android_minimum_version': '7', @@ -50,22 +52,47 @@ } MOCK_ACTION_CODE_SETTINGS = auth.ActionCodeSettings(**MOCK_ACTION_CODE_DATA) -USER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v1/projects/mock-project-id' +TEST_TIMEOUT = 42 +ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1' +EMULATOR_HOST_ENV_VAR = 'FIREBASE_AUTH_EMULATOR_HOST' +AUTH_EMULATOR_HOST = 'localhost:9099' +EMULATED_ID_TOOLKIT_URL = f'http://{AUTH_EMULATOR_HOST}/identitytoolkit.googleapis.com/v1' +URL_PROJECT_SUFFIX = '/projects/mock-project-id' +USER_MGT_URLS = { + 'ID_TOOLKIT': ID_TOOLKIT_URL, + 'PREFIX': ID_TOOLKIT_URL + URL_PROJECT_SUFFIX, +} -@pytest.fixture(scope='module') -def user_mgt_app(): +@pytest.fixture(params=[{'emulated': False}, {'emulated': True}]) +def user_mgt_app(request): + monkeypatch = testutils.new_monkeypatch() + if request.param['emulated']: + monkeypatch.setenv(EMULATOR_HOST_ENV_VAR, AUTH_EMULATOR_HOST) + monkeypatch.setitem(USER_MGT_URLS, 'ID_TOOLKIT', EMULATED_ID_TOOLKIT_URL) + monkeypatch.setitem(USER_MGT_URLS, 'PREFIX', EMULATED_ID_TOOLKIT_URL + URL_PROJECT_SUFFIX) app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt', options={'projectId': 'mock-project-id'}) yield app firebase_admin.delete_app(app) + monkeypatch.undo() + +@pytest.fixture +def user_mgt_app_with_timeout(): + app = firebase_admin.initialize_app( + testutils.MockCredential(), + name='userMgtTimeout', + options={'projectId': 'mock-project-id', 'httpTimeout': TEST_TIMEOUT} + ) + yield app + firebase_admin.delete_app(app) def _instrument_user_manager(app, status, payload): client = auth._get_client(app) user_manager = client._user_manager recorder = [] user_manager.http_client.session.mount( - _user_mgt.UserManager.ID_TOOLKIT_URL, + USER_MGT_URLS['ID_TOOLKIT'], testutils.MockAdapter(payload, status, recorder)) return user_manager, recorder @@ -105,14 +132,21 @@ def _check_user_record(user, expected_uid='testuser'): assert provider.provider_id == 'phone' -def _check_request(recorder, want_url, want_body=None): +def _check_request(recorder, want_url, want_body=None, want_timeout=None): assert len(recorder) == 1 req = recorder[0] assert req.method == 'POST' - assert req.url == '{0}{1}'.format(USER_MGT_URL_PREFIX, want_url) + assert req.url == f'{USER_MGT_URLS["PREFIX"]}{want_url}' + expected_metrics_header = [ + _utils.get_metrics_header(), + _utils.get_metrics_header() + ' mock-cred-metric-tag' + ] + assert req.headers['x-goog-api-client'] in expected_metrics_header if want_body: body = json.loads(req.body.decode()) assert body == want_body + if want_timeout: + assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(want_timeout, 0.001) class TestAuthServiceInitialization: @@ -122,6 +156,11 @@ def test_default_timeout(self, user_mgt_app): user_manager = client._user_manager assert user_manager.http_client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS + def test_app_options_timeout(self, user_mgt_app_with_timeout): + client = auth._get_client(user_mgt_app_with_timeout) + user_manager = client._user_manager + assert user_manager.http_client.timeout == TEST_TIMEOUT + def test_fail_on_no_project_id(self): app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt2') with pytest.raises(ValueError): @@ -225,6 +264,12 @@ def test_get_user(self, user_mgt_app): _check_user_record(auth.get_user('testuser', user_mgt_app)) _check_request(recorder, '/accounts:lookup', {'localId': ['testuser']}) + def test_get_user_with_timeout(self, user_mgt_app_with_timeout): + _, recorder = _instrument_user_manager( + user_mgt_app_with_timeout, 200, MOCK_GET_USER_RESPONSE) + _check_user_record(auth.get_user('testuser', user_mgt_app_with_timeout)) + _check_request(recorder, '/accounts:lookup', {'localId': ['testuser']}, TEST_TIMEOUT) + @pytest.mark.parametrize('arg', INVALID_STRINGS + ['not-an-email']) def test_invalid_get_user_by_email(self, arg, user_mgt_app): with pytest.raises(ValueError): @@ -494,7 +539,7 @@ def test_user_already_exists(self, user_mgt_app, error_code): with pytest.raises(exc_type) as excinfo: auth.create_user(app=user_mgt_app) assert isinstance(excinfo.value, exceptions.AlreadyExistsError) - assert str(excinfo.value) == '{0} ({1}).'.format(exc_type.default_message, error_code) + assert str(excinfo.value) == f'{exc_type.default_message} ({error_code}).' assert excinfo.value.http_response is not None assert excinfo.value.cause is not None @@ -625,6 +670,23 @@ def test_update_user_valid_since(self, user_mgt_app, arg): request = json.loads(recorder[0].body.decode()) assert request == {'localId': 'testuser', 'validSince': int(arg)} + @pytest.mark.parametrize('arg', [['phone'], ['google.com', 'phone']]) + def test_update_user_delete_provider(self, user_mgt_app, arg): + user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') + user_mgt.update_user('testuser', providers_to_delete=arg) + request = json.loads(recorder[0].body.decode()) + assert set(request['deleteProvider']) == set(arg) + + @pytest.mark.parametrize('arg', [[], ['phone'], ['google.com'], ['google.com', 'phone']]) + def test_update_user_delete_provider_and_phone(self, user_mgt_app, arg): + user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') + user_mgt.update_user('testuser', + providers_to_delete=arg, + phone_number=auth.DELETE_ATTRIBUTE) + request = json.loads(recorder[0].body.decode()) + assert 'phone' in request['deleteProvider'] + assert len(set(request['deleteProvider'])) == len(request['deleteProvider']) + assert set(arg) - set(request['deleteProvider']) == set() class TestSetCustomUserClaims: @@ -643,15 +705,14 @@ def test_single_reserved_claim(self, user_mgt_app, key): claims = {key : 'value'} with pytest.raises(ValueError) as excinfo: auth.set_custom_user_claims('user', claims, app=user_mgt_app) - assert str(excinfo.value) == 'Claim "{0}" is reserved, and must not be set.'.format(key) + assert str(excinfo.value) == f'Claim "{key}" is reserved, and must not be set.' def test_multiple_reserved_claims(self, user_mgt_app): claims = {key : 'value' for key in _auth_utils.RESERVED_CLAIMS} with pytest.raises(ValueError) as excinfo: auth.set_custom_user_claims('user', claims, app=user_mgt_app) joined = ', '.join(sorted(claims.keys())) - assert str(excinfo.value) == ('Claims "{0}" are reserved, and must not be ' - 'set.'.format(joined)) + assert str(excinfo.value) == f'Claims "{joined}" are reserved, and must not be set.' def test_large_claims_payload(self, user_mgt_app): claims = {'key' : 'A'*1000} @@ -769,12 +830,12 @@ def test_success(self, user_mgt_app): class TestListUsers: - @pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 1001, False]) + @pytest.mark.parametrize('arg', [None, 'foo', [], {}, 0, -1, 1001, False]) def test_invalid_max_results(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_users(max_results=arg, app=user_mgt_app) - @pytest.mark.parametrize('arg', ['', list(), dict(), 0, -1, 1001, False]) + @pytest.mark.parametrize('arg', ['', [], {}, 0, -1, 1001, False]) def test_invalid_page_token(self, user_mgt_app, arg): with pytest.raises(ValueError): auth.list_users(page_token=arg, app=user_mgt_app) @@ -826,7 +887,7 @@ def test_list_users_paged_iteration(self, user_mgt_app): iterator = page.iterate_all() for index in range(3): user = next(iterator) - assert user.uid == 'user{0}'.format(index+1) + assert user.uid == f'user{index+1}' assert len(recorder) == 1 self._check_rpc_calls(recorder) @@ -851,7 +912,7 @@ def test_list_users_iterator_state(self, user_mgt_app): iterator = page.iterate_all() for user in iterator: index += 1 - assert user.uid == 'user{0}'.format(index) + assert user.uid == f'user{index}' if index == 2: break @@ -925,7 +986,7 @@ def _check_page(self, page): assert len(page.users) == 2 for user in page.users: assert isinstance(user, auth.ExportedUserRecord) - _check_user_record(user, 'testuser{0}'.format(index)) + _check_user_record(user, f'testuser{index}') assert user.password_hash == 'passwordHash' assert user.password_salt == 'passwordSalt' index += 1 @@ -1000,8 +1061,8 @@ class TestImportUserRecord: [{'email': arg} for arg in INVALID_STRINGS[1:] + ['not-an-email']] + [{'photo_url': arg} for arg in INVALID_STRINGS[1:] + ['not-a-url']] + [{'phone_number': arg} for arg in INVALID_STRINGS[1:] + ['not-a-phone']] + - [{'password_hash': arg} for arg in INVALID_STRINGS[1:] + [u'test']] + - [{'password_salt': arg} for arg in INVALID_STRINGS[1:] + [u'test']] + + [{'password_hash': arg} for arg in INVALID_STRINGS[1:] + ['test']] + + [{'password_salt': arg} for arg in INVALID_STRINGS[1:] + ['test']] + [{'custom_claims': arg} for arg in INVALID_DICTS[1:] + ['"json"', {'key': 'a'*1000}]] + [{'provider_data': arg} for arg in ['foo', 1, True]] ) @@ -1157,7 +1218,7 @@ def test_standard_scrypt(self): memory_cost=14, parallelization=2, block_size=10, derived_key_length=128) expected = { 'hashAlgorithm': 'STANDARD_SCRYPT', - 'memoryCost': 14, + 'cpuMemCost': 14, 'parallelization': 2, 'blockSize': 10, 'dkLen': 128, @@ -1184,13 +1245,13 @@ def test_invalid_standard_scrypt(self, arg): class TestImportUsers: - @pytest.mark.parametrize('arg', [None, list(), tuple(), dict(), 0, 1, 'foo']) + @pytest.mark.parametrize('arg', [None, [], tuple(), {}, 0, 1, 'foo']) def test_invalid_users(self, user_mgt_app, arg): with pytest.raises(Exception): auth.import_users(arg, app=user_mgt_app) def test_too_many_users(self, user_mgt_app): - users = [auth.ImportUserRecord(uid='test{0}'.format(i)) for i in range(1001)] + users = [auth.ImportUserRecord(uid=f'test{i}') for i in range(1001)] with pytest.raises(ValueError): auth.import_users(users, app=user_mgt_app) @@ -1303,7 +1364,8 @@ def test_valid_data(self): data = { 'url': 'http://localhost', 'handle_code_in_app': True, - 'dynamic_link_domain': 'http://testly', + 'dynamic_link_domain': 'http://dynamic-link-domain', + 'link_domain': 'http://link-domain', 'ios_bundle_id': 'test.bundle', 'android_package_name': 'test.bundle', 'android_minimum_version': '7', @@ -1314,7 +1376,8 @@ def test_valid_data(self): assert parameters['continueUrl'] == data['url'] assert parameters['canHandleCodeInApp'] == data['handle_code_in_app'] assert parameters['dynamicLinkDomain'] == data['dynamic_link_domain'] - assert parameters['iosBundleId'] == data['ios_bundle_id'] + assert parameters['linkDomain'] == data['link_domain'] + assert parameters['iOSBundleId'] == data['ios_bundle_id'] assert parameters['androidPackageName'] == data['android_package_name'] assert parameters['androidMinimumVersion'] == data['android_minimum_version'] assert parameters['androidInstallApp'] == data['android_install_app'] @@ -1323,7 +1386,7 @@ def test_valid_data(self): {'android_install_app':'nonboolean'}, {'dynamic_link_domain': False}, {'ios_bundle_id':11}, - {'android_package_name':dict()}, + {'android_package_name':{}}, {'android_minimum_version':tuple()}, {'android_minimum_version':'7'}, {'android_install_app': True}]) @@ -1408,6 +1471,17 @@ def test_api_call_failure(self, user_mgt_app, func): assert excinfo.value.http_response is not None assert excinfo.value.cause is not None + def test_password_reset_non_existing(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 400, '{"error":{"message": "EMAIL_NOT_FOUND"}}') + with pytest.raises(auth.EmailNotFoundError) as excinfo: + auth.generate_password_reset_link( + 'nonexistent@user', MOCK_ACTION_CODE_SETTINGS, app=user_mgt_app) + error_msg = 'No user record found for the given email (EMAIL_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + @pytest.mark.parametrize('func', [ auth.generate_sign_in_with_email_link, auth.generate_email_verification_link, @@ -1425,6 +1499,23 @@ def test_invalid_dynamic_link(self, user_mgt_app, func): assert excinfo.value.http_response is not None assert excinfo.value.cause is not None + @pytest.mark.parametrize('func', [ + auth.generate_sign_in_with_email_link, + auth.generate_email_verification_link, + auth.generate_password_reset_link, + ]) + def test_invalid_hosting_link(self, user_mgt_app, func): + resp = '{"error":{"message": "INVALID_HOSTING_LINK_DOMAIN: Because of this reason."}}' + _instrument_user_manager(user_mgt_app, 500, resp) + with pytest.raises(auth.InvalidHostingLinkDomainError) as excinfo: + func('test@test.com', MOCK_ACTION_CODE_SETTINGS, app=user_mgt_app) + assert isinstance(excinfo.value, exceptions.InvalidArgumentError) + assert str(excinfo.value) == ('The provided hosting link domain is not configured in ' + 'Firebase Hosting or is not owned by the current project ' + '(INVALID_HOSTING_LINK_DOMAIN). Because of this reason.') + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + @pytest.mark.parametrize('func', [ auth.generate_sign_in_with_email_link, auth.generate_email_verification_link, @@ -1463,7 +1554,8 @@ def _validate_request(self, request, settings=None): assert request['continueUrl'] == settings.url assert request['canHandleCodeInApp'] == settings.handle_code_in_app assert request['dynamicLinkDomain'] == settings.dynamic_link_domain - assert request['iosBundleId'] == settings.ios_bundle_id + assert request['linkDomain'] == settings.link_domain + assert request['iOSBundleId'] == settings.ios_bundle_id assert request['androidPackageName'] == settings.android_package_name assert request['androidMinimumVersion'] == settings.android_minimum_version assert request['androidInstallApp'] == settings.android_install_app diff --git a/tests/testutils.py b/tests/testutils.py index d0663ead1..7546595af 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -16,7 +16,9 @@ import io import os -from google.auth import credentials +import pytest + +from google.auth import credentials, compute_engine from google.auth import transport from requests import adapters from requests import models @@ -31,7 +33,7 @@ def resource_filename(filename): def resource(filename): """Returns the contents of a test resource.""" - with open(resource_filename(filename), 'r') as file_obj: + with open(resource_filename(filename), 'r', encoding='utf-8') as file_obj: return file_obj.read() @@ -58,6 +60,10 @@ def run_without_project_id(func): os.environ[env_var] = gcloud_project +def new_monkeypatch(): + return pytest.MonkeyPatch() + + class MockResponse(transport.Response): def __init__(self, status, response): self._status = status @@ -105,10 +111,34 @@ def __call__(self, *args, **kwargs): # pylint: disable=arguments-differ raise self.error +# Temporarily disable the lint rule. For more information see: +# https://github.com/googleapis/google-auth-library-python/pull/561 +# pylint: disable=abstract-method class MockGoogleCredential(credentials.Credentials): """A mock Google authentication credential.""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + def refresh(self, request): self.token = 'mock-token' + self._service_account_email = 'mock-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state + + @property + def service_account_email(self): + return self._service_account_email + + # Simulate x-goog-api-client modification in credential refresh + def _metric_header_for_usage(self): + return 'mock-cred-metric-tag' class MockCredential(firebase_admin.credentials.Base): @@ -120,6 +150,35 @@ def __init__(self): def get_credential(self): return self._g_credential +class MockGoogleComputeEngineCredential(compute_engine.Credentials): + """A mock Compute Engine credential""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + + def refresh(self, request): + self.token = 'mock-compute-engine-token' + self._service_account_email = 'mock-gce-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state + + def _metric_header_for_usage(self): + return 'mock-gce-cred-metric-tag' + +class MockComputeEngineCredential(firebase_admin.credentials.Base): + """A mock Firebase credential implementation.""" + + def __init__(self): + self._g_credential = MockGoogleComputeEngineCredential() + + def get_credential(self): + return self._g_credential class MockMultiRequestAdapter(adapters.HTTPAdapter): """A mock HTTP adapter that supports multiple responses for the Python requests module.""" @@ -153,7 +212,7 @@ def send(self, request, **kwargs): # pylint: disable=arguments-differ class MockAdapter(MockMultiRequestAdapter): """A mock HTTP adapter for the Python requests module.""" def __init__(self, data, status, recorder): - super(MockAdapter, self).__init__([data], [status], recorder) + super().__init__([data], [status], recorder) @property def status(self): @@ -162,3 +221,73 @@ def status(self): @property def data(self): return self._responses[0] + +class MockRequestBasedMultiRequestAdapter(adapters.HTTPAdapter): + """A mock HTTP adapter that supports multiple responses for the Python requests module. + The response for each incoming request should be specified in response_dict during + initialization. Each incoming request should contain an identifier in the its body.""" + def __init__(self, response_dict, recorder): + """Constructs a MockRequestBasedMultiRequestAdapter. + + Each incoming request consumes the response and status mapped to it. If no response + is specified for the request, the response will be 404 with an empty body. + """ + adapters.HTTPAdapter.__init__(self) + self._current_response = 0 + self._response_dict = dict(response_dict) + self._recorder = recorder + + def send(self, request, **kwargs): # pylint: disable=arguments-differ + request._extra_kwargs = kwargs + self._recorder.append(request) + resp = models.Response() + resp.url = request.url + resp.status_code = 404 # Not found. + resp.raw = None + for req_id, pair in self._response_dict.items(): + if req_id in str(request.body): + status, response = pair + resp.status_code = status + resp.raw = io.BytesIO(response.encode()) + break + return resp + +def build_mock_condition(name, condition): + return { + 'name': name, + 'condition': condition, + } + +def build_mock_parameter(name, description, value=None, + conditional_values=None, default_value=None, parameter_groups=None): + return { + 'name': name, + 'description': description, + 'value': value, + 'conditionalValues': conditional_values, + 'defaultValue': default_value, + 'parameterGroups': parameter_groups, + } + +def build_mock_conditional_value(condition_name, value): + return { + 'conditionName': condition_name, + 'value': value, + } + +def build_mock_default_value(value): + return { + 'value': value, + } + +def build_mock_parameter_group(name, description, parameters): + return { + 'name': name, + 'description': description, + 'parameters': parameters, + } + +def build_mock_version(version_number): + return { + 'versionNumber': version_number, + }