diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg new file mode 100644 index 00000000..145389e0 Binary files /dev/null and b/.github/resources/integ-service-account.json.gpg differ diff --git a/.github/scripts/generate_changelog.sh b/.github/scripts/generate_changelog.sh new file mode 100755 index 00000000..e393f40e --- /dev/null +++ b/.github/scripts/generate_changelog.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Copyright 2020 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. + +set -e +set -u + +function printChangelog() { + local TITLE=$1 + shift + # Skip the sentinel value. + local ENTRIES=("${@:2}") + if [ ${#ENTRIES[@]} -ne 0 ]; then + echo "### ${TITLE}" + echo "" + for ((i = 0; i < ${#ENTRIES[@]}; i++)) + do + echo "* ${ENTRIES[$i]}" + done + echo "" + fi +} + +if [[ -z "${GITHUB_SHA}" ]]; then + GITHUB_SHA="HEAD" +fi + +LAST_TAG=`git describe --tags $(git rev-list --tags --max-count=1) 2> /dev/null` || true +if [[ -z "${LAST_TAG}" ]]; then + echo "[INFO] No tags found. Including all commits up to ${GITHUB_SHA}." + VERSION_RANGE="${GITHUB_SHA}" +else + echo "[INFO] Last release tag: ${LAST_TAG}." + COMMIT_SHA=`git show-ref -s ${LAST_TAG}` + echo "[INFO] Last release commit: ${COMMIT_SHA}." + VERSION_RANGE="${COMMIT_SHA}..${GITHUB_SHA}" + echo "[INFO] Including all commits in the range ${VERSION_RANGE}." +fi + +echo "" + +# Older versions of Bash (< 4.4) treat empty arrays as unbound variables, which triggers +# errors when referencing them. Therefore we initialize each of these arrays with an empty +# sentinel value, and later skip them. +CHANGES=("") +FIXES=("") +FEATS=("") +MISC=("") + +while read -r line +do + COMMIT_MSG=`echo ${line} | cut -d ' ' -f 2-` + if [[ $COMMIT_MSG =~ ^change(\(.*\))?: ]]; then + CHANGES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^fix(\(.*\))?: ]]; then + FIXES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^feat(\(.*\))?: ]]; then + FEATS+=("$COMMIT_MSG") + else + MISC+=("${COMMIT_MSG}") + fi +done < <(git log ${VERSION_RANGE} --oneline) + +printChangelog "Breaking Changes" "${CHANGES[@]}" +printChangelog "New Features" "${FEATS[@]}" +printChangelog "Bug Fixes" "${FIXES[@]}" +printChangelog "Miscellaneous" "${MISC[@]}" diff --git a/.github/scripts/publish_post_check.sh b/.github/scripts/publish_post_check.sh new file mode 100755 index 00000000..8311ec25 --- /dev/null +++ b/.github/scripts/publish_post_check.sh @@ -0,0 +1,105 @@ + +#!/bin/bash + +# Copyright 2020 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. + + +###################################### Outputs ##################################### + +# 1. version: The version of this release including the 'v' prefix (e.g. v1.2.3). +# 2. changelog: Formatted changelog text for this release. + +#################################################################################### + +set -e +set -u + +function echo_info() { + local MESSAGE=$1 + echo "[INFO] ${MESSAGE}" +} + +function echo_warn() { + local MESSAGE=$1 + echo "[WARN] ${MESSAGE}" +} + +function terminate() { + echo "" + echo_warn "--------------------------------------------" + echo_warn "POST CHECK FAILED" + echo_warn "--------------------------------------------" + exit 1 +} + + +echo_info "Starting publish post check..." +echo_info "Git revision : ${GITHUB_SHA}" +echo_info "Git ref : ${GITHUB_REF}" +echo_info "Workflow triggered by : ${GITHUB_ACTOR}" +echo_info "GitHub event : ${GITHUB_EVENT_NAME}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Extracting release version" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "Loading version from: firebase.go" + +readonly RELEASE_VERSION=`grep "const Version" firebase.go | awk '{print $4}' | tr -d \"` || true +if [[ -z "${RELEASE_VERSION}" ]]; then + echo_warn "Failed to extract release version from: firebase.go" + terminate +fi + +if [[ ! "${RELEASE_VERSION}" =~ ^([0-9]*)\.([0-9]*)\.([0-9]*)$ ]]; then + echo_warn "Malformed release version string: ${RELEASE_VERSION}. Exiting." + terminate +fi + +echo_info "Extracted release version: ${RELEASE_VERSION}" +echo "::set-output name=version::v${RELEASE_VERSION}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Generating changelog" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch origin master --prune --unshallow >---" +git fetch origin master --prune --unshallow +echo "" + +echo_info "Generating changelog from history..." +readonly CURRENT_DIR=$(dirname "$0") +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 +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}" + + +echo "" +echo_info "--------------------------------------------" +echo_info "POST CHECK SUCCESSFUL" +echo_info "--------------------------------------------" diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh new file mode 100755 index 00000000..9dad71bb --- /dev/null +++ b/.github/scripts/publish_preflight_check.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Copyright 2020 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. + + +set -e +set -u + +function echo_info() { + local MESSAGE=$1 + echo "[INFO] ${MESSAGE}" +} + +function echo_warn() { + local MESSAGE=$1 + echo "[WARN] ${MESSAGE}" +} + +function terminate() { + echo "" + echo_warn "--------------------------------------------" + echo_warn "PREFLIGHT FAILED" + echo_warn "--------------------------------------------" + exit 1 +} + + +echo_info "Starting publish preflight check..." +echo_info "Git revision : ${GITHUB_SHA}" +echo_info "Git ref : ${GITHUB_REF}" +echo_info "Workflow triggered by : ${GITHUB_ACTOR}" +echo_info "GitHub event : ${GITHUB_EVENT_NAME}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Extracting release version" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "Loading version from: firebase.go" + +readonly RELEASE_VERSION=`grep "const Version" firebase.go | awk '{print $4}' | tr -d \"` || true +if [[ -z "${RELEASE_VERSION}" ]]; then + echo_warn "Failed to extract release version from: firebase.go" + terminate +fi + +if [[ ! "${RELEASE_VERSION}" =~ ^([0-9]*)\.([0-9]*)\.([0-9]*)$ ]]; then + echo_warn "Malformed release version string: ${RELEASE_VERSION}. Exiting." + terminate +fi + +echo_info "Extracted release version: ${RELEASE_VERSION}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Checking release tag" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch --depth=1 origin +refs/tags/*:refs/tags/* >---" +git fetch --depth=1 origin +refs/tags/*:refs/tags/* +echo "" + +readonly EXISTING_TAG=`git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}"` || true +if [[ -n "${EXISTING_TAG}" ]]; then + echo_warn "Tag v${RELEASE_VERSION} already exists. Exiting." + echo_warn "If the tag was created in a previous unsuccessful attempt, delete it and try again." + echo_warn " $ git tag -d v${RELEASE_VERSION}" + echo_warn " $ git push --delete origin v${RELEASE_VERSION}" + + readonly RELEASE_URL="https://github.com/firebase/firebase-admin-go/releases/tag/v${RELEASE_VERSION}" + echo_warn "Delete any corresponding releases at ${RELEASE_URL}." + terminate +fi + +echo_info "Tag v${RELEASE_VERSION} does not exist." + + +echo "" +echo_info "--------------------------------------------" +echo_info "PREFLIGHT SUCCESSFUL" +echo_info "--------------------------------------------" diff --git a/.github/scripts/run_all_tests.sh b/.github/scripts/run_all_tests.sh new file mode 100755 index 00000000..b52b283c --- /dev/null +++ b/.github/scripts/run_all_tests.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Copyright 2020 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. + +set -e +set -u + +gpg --quiet --batch --yes --decrypt --passphrase="${FIREBASE_SERVICE_ACCT_KEY}" \ + --output testdata/integration_cert.json .github/resources/integ-service-account.json.gpg + +echo "${FIREBASE_API_KEY}" > testdata/integration_apikey.txt + +go test -v -race firebase.google.com/go/... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37149aec..fb8f93ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -name: Go Continuous Integration -on: [push, pull_request] +name: Continuous Integration +on: pull_request jobs: build: @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v2 with: path: go/src/firebase.google.com/go - + - name: Get dependencies run: go get -t -v $(go list ./... | grep -v integration) @@ -43,7 +43,7 @@ jobs: echo "Go code is not formatted:" gofmt -d -s . exit 1 - fi + fi - name: Run Static Analyzer run: go vet -v firebase.google.com/go/... diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..26609cab --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,64 @@ +# Copyright 2020 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: Publish Release + +on: + # Only run the workflow when a PR is merged to the master branch. + pull_request: + branches: master + types: closed + +jobs: + publish_release: + if: github.event.pull_request.merged + + runs-on: ubuntu-latest + + steps: + - name: Checkout source + uses: actions/checkout@v2 + + - name: Publish post check + id: postcheck + run: ./.github/scripts/publish_post_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. + - name: Create release tag + uses: fleskesvor/create-release@1a72e235c178bf2ae6c51a8ae36febc24568c5fe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.postcheck.outputs.version }} + release_name: Firebase Admin Go SDK ${{ steps.postcheck.outputs.version }} + body: ${{ steps.postcheck.outputs.changelog }} + draft: false + prerelease: false + + # 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.postcheck.outputs.version }} of @Firebase Admin Go SDK is avaialble. + https://github.com/firebase/firebase-admin-go/releases/tag/${{ steps.postcheck.outputs.version }} + consumer-key: ${{ secrets.FIREBASE_TWITTER_CONSUMER_KEY }} + consumer-secret: ${{ secrets.FIREBASE_TWITTER_CONSUMER_SECRET }} + access-token: ${{ secrets.FIREBASE_TWITTER_ACCESS_TOKEN }} + access-token-secret: ${{ secrets.FIREBASE_TWITTER_ACCESS_TOKEN_SECRET }} + continue-on-error: true diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml new file mode 100644 index 00000000..39ff581a --- /dev/null +++ b/.github/workflows/stage.yml @@ -0,0 +1,74 @@ +# Copyright 2020 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: Stage Release + +on: + # Only run the workflow when a PR is updated or when a developer explicitly requests + # a build by sending a 'firebase_build' event. + pull_request: + types: [opened, synchronize] + + repository_dispatch: + types: + - firebase_build + +jobs: + stage_release: + # To stage a release without publishing it, send a 'firebase_build' event or apply + # the 'release:stage' label to a PR. PRs targetting the master branch are always + # staged. + if: github.event.action == 'firebase_build' || + contains(github.event.pull_request.labels.*.name, 'release:stage') || + github.event.pull_request.base.ref == 'master' + + runs-on: ubuntu-latest + + env: + GOPATH: ${{ github.workspace }}/go + + # When manually triggering the build, the requester can specify a target branch or a tag + # via the 'ref' client parameter. + steps: + - name: Check out code into GOPATH + uses: actions/checkout@v2 + with: + path: go/src/firebase.google.com/go + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.11 + + - name: Get dependencies + run: go get -t -v $(go list ./... | grep -v integration) + + - name: Run Linter + run: | + echo + go get golang.org/x/lint/golint + $GOPATH/bin/golint -set_exit_status firebase.google.com/go/... + + - name: Run Tests + working-directory: ./go/src/firebase.google.com/go + run: ./.github/scripts/run_all_tests.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # If triggered by a PR against the master branch, run additional checks. + - name: Publish preflight check + if: github.event.pull_request.base.ref == 'master' + run: ./.github/scripts/publish_preflight_check.sh diff --git a/.travis.gofmt.sh b/.travis.gofmt.sh deleted file mode 100755 index e33451d7..00000000 --- a/.travis.gofmt.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -if [[ ! -z "$(gofmt -l -s .)" ]]; then - echo "Go code is not formatted:" - gofmt -d -s . - exit 1 -fi diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a4290f13..00000000 --- a/.travis.yml +++ /dev/null @@ -1,27 +0,0 @@ -language: go - -go: - - "1.11.x" - - "1.12.x" - - "1.13.x" - - master - -matrix: - # Build OK if fails on unstable development versions of Go. - allow_failures: - - go: master - # Don't wait for tests to finish on allow_failures. - # Mark the build finished if tests pass on other versions of Go. - fast_finish: true - -go_import_path: firebase.google.com/go - -install: - - go get golang.org/x/lint/golint - - go get -t -v $(go list ./... | grep -v integration) - -script: - - golint -set_exit_status $(go list ./...) - - if [[ "$TRAVIS_GO_VERSION" =~ ^1\.11\.([0-9]+|x)$ ]]; then ./.travis.gofmt.sh; fi - - go test -v -race -test.short ./... # Run tests with the race detector. - - go vet -v ./... # Run Go static analyzer. diff --git a/README.md b/README.md index 4b0e87b8..899a88d4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/firebase/firebase-admin-go.svg?branch=master)](https://travis-ci.org/firebase/firebase-admin-go) +[![Build Status](https://github.com/firebase/firebase-admin-go/workflows/Continuous%20Integration/badge.svg)](https://github.com/firebase/firebase-admin-go/actions) [![GoDoc](https://godoc.org/firebase.google.com/go?status.svg)](https://godoc.org/firebase.google.com/go) [![Go Report Card](https://goreportcard.com/badge/github.com/firebase/firebase-admin-go)](https://goreportcard.com/report/github.com/firebase/firebase-admin-go) @@ -42,7 +42,7 @@ requests, code review feedback, and also pull requests. ## Supported Go Versions We support Go v1.11 and higher. -[Continuous integration](https://travis-ci.org/firebase/firebase-admin-go) system +[Continuous integration](https://github.com/firebase/firebase-admin-go/actions) system tests the code on Go v1.11 through v1.13. ## Documentation diff --git a/auth/auth.go b/auth/auth.go index c12a82ed..b5088806 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -57,14 +57,18 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error) signer cryptoSigner err error ) + + creds, _ := transport.Creds(ctx, conf.Opts...) + // Initialize a signer by following the go/firebase-admin-sign protocol. - if conf.Creds != nil && len(conf.Creds.JSON) > 0 { + if creds != nil && len(creds.JSON) > 0 { // If the SDK was initialized with a service account, use it to sign bytes. - signer, err = signerFromCreds(conf.Creds.JSON) + signer, err = signerFromCreds(creds.JSON) if err != nil && err != errNotAServiceAcct { return nil, err } } + if signer == nil { if conf.ServiceAccountID != "" { // If the SDK was initialized with a service account email, use it with the IAM service diff --git a/auth/auth_test.go b/auth/auth_test.go index 72ad19d9..1e8e1098 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -34,6 +34,7 @@ import ( ) const ( + credEnvVar = "GOOGLE_APPLICATION_CREDENTIALS" testProjectID = "mock-project-id" testVersion = "test-version" ) @@ -82,7 +83,6 @@ func TestNewClientWithServiceAccountCredentials(t *testing.T) { t.Fatal(err) } client, err := NewClient(context.Background(), &internal.AuthConfig{ - Creds: creds, Opts: optsWithServiceAcct, ProjectID: creds.ProjectID, Version: testVersion, @@ -176,7 +176,6 @@ func TestNewClientWithUserCredentials(t *testing.T) { }`), } conf := &internal.AuthConfig{ - Creds: creds, Opts: []option.ClientOption{option.WithCredentials(creds)}, Version: testVersion, } @@ -206,7 +205,11 @@ func TestNewClientWithMalformedCredentials(t *testing.T) { creds := &google.DefaultCredentials{ JSON: []byte("not json"), } - conf := &internal.AuthConfig{Creds: creds} + conf := &internal.AuthConfig{ + Opts: []option.ClientOption{ + option.WithCredentials(creds), + }, + } if c, err := NewClient(context.Background(), conf); c != nil || err == nil { t.Errorf("NewClient() = (%v,%v); want = (nil, error)", c, err) } @@ -222,12 +225,61 @@ func TestNewClientWithInvalidPrivateKey(t *testing.T) { t.Fatal(err) } creds := &google.DefaultCredentials{JSON: b} - conf := &internal.AuthConfig{Creds: creds} + conf := &internal.AuthConfig{ + Opts: []option.ClientOption{ + option.WithCredentials(creds), + }, + } if c, err := NewClient(context.Background(), conf); c != nil || err == nil { t.Errorf("NewClient() = (%v,%v); want = (nil, error)", c, err) } } +func TestNewClientAppDefaultCredentialsWithInvalidFile(t *testing.T) { + current := os.Getenv(credEnvVar) + + if err := os.Setenv(credEnvVar, "../testdata/non_existing.json"); err != nil { + t.Fatal(err) + } + defer os.Setenv(credEnvVar, current) + + conf := &internal.AuthConfig{} + if c, err := NewClient(context.Background(), conf); c != nil || err == nil { + t.Errorf("Auth() = (%v, %v); want (nil, error)", c, err) + } +} + +func TestNewClientInvalidCredentialFile(t *testing.T) { + invalidFiles := []string{ + "testdata", + "testdata/plain_text.txt", + } + + ctx := context.Background() + for _, tc := range invalidFiles { + conf := &internal.AuthConfig{ + Opts: []option.ClientOption{ + option.WithCredentialsFile(tc), + }, + } + if c, err := NewClient(ctx, conf); c != nil || err == nil { + t.Errorf("Auth() = (%v, %v); want (nil, error)", c, err) + } + } +} + +func TestNewClientExplicitNoAuth(t *testing.T) { + ctx := context.Background() + conf := &internal.AuthConfig{ + Opts: []option.ClientOption{ + option.WithoutAuthentication(), + }, + } + if c, err := NewClient(ctx, conf); c == nil || err != nil { + t.Errorf("Auth() = (%v, %v); want (auth, nil)", c, err) + } +} + func TestCustomToken(t *testing.T) { client := &Client{ signer: testSigner, @@ -298,8 +350,7 @@ func TestCustomTokenError(t *testing.T) { func TestCustomTokenInvalidCredential(t *testing.T) { ctx := context.Background() conf := &internal.AuthConfig{ - Creds: nil, - Opts: optsWithTokenSource, + Opts: optsWithTokenSource, } s, err := NewClient(ctx, conf) if err != nil { diff --git a/firebase.go b/firebase.go index d66b1fb0..62426ed1 100644 --- a/firebase.go +++ b/firebase.go @@ -31,7 +31,6 @@ import ( "firebase.google.com/go/internal" "firebase.google.com/go/messaging" "firebase.google.com/go/storage" - "golang.org/x/oauth2/google" "google.golang.org/api/option" "google.golang.org/api/transport" ) @@ -47,7 +46,6 @@ const firebaseEnvName = "FIREBASE_CONFIG" // An App holds configuration and state common to all Firebase services that are exposed from the SDK. type App struct { authOverride map[string]interface{} - creds *google.DefaultCredentials dbURL string projectID string serviceAccountID string @@ -67,7 +65,6 @@ type Config struct { // Auth returns an instance of auth.Client. func (a *App) Auth(ctx context.Context) (*auth.Client, error) { conf := &internal.AuthConfig{ - Creds: a.creds, ProjectID: a.projectID, Opts: a.opts, ServiceAccountID: a.serviceAccountID, @@ -142,28 +139,14 @@ func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) { func NewApp(ctx context.Context, config *Config, opts ...option.ClientOption) (*App, error) { o := []option.ClientOption{option.WithScopes(internal.FirebaseScopes...)} o = append(o, opts...) - creds, err := transport.Creds(ctx, o...) - if err != nil { - return nil, err - } if config == nil { + var err error if config, err = getConfigDefaults(); err != nil { return nil, err } } - var pid string - if config.ProjectID != "" { - pid = config.ProjectID - } else if creds.ProjectID != "" { - pid = creds.ProjectID - } else { - pid = os.Getenv("GOOGLE_CLOUD_PROJECT") - if pid == "" { - pid = os.Getenv("GCLOUD_PROJECT") - } - } - + pid := getProjectID(ctx, config, o...) ao := defaultAuthOverrides if config.AuthOverride != nil { ao = *config.AuthOverride @@ -171,7 +154,6 @@ func NewApp(ctx context.Context, config *Config, opts ...option.ClientOption) (* return &App{ authOverride: ao, - creds: creds, dbURL: config.DatabaseURL, projectID: pid, serviceAccountID: config.ServiceAccountID, @@ -213,3 +195,20 @@ func getConfigDefaults() (*Config, error) { } return fbc, nil } + +func getProjectID(ctx context.Context, config *Config, opts ...option.ClientOption) string { + if config.ProjectID != "" { + return config.ProjectID + } + + creds, _ := transport.Creds(ctx, opts...) + if creds != nil && creds.ProjectID != "" { + return creds.ProjectID + } + + if pid := os.Getenv("GOOGLE_CLOUD_PROJECT"); pid != "" { + return pid + } + + return os.Getenv("GCLOUD_PROJECT") +} diff --git a/firebase_test.go b/firebase_test.go index 831712f4..1b367f1c 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -58,11 +58,6 @@ func TestServiceAcctFile(t *testing.T) { if len(app.opts) != 2 { t.Errorf("Client opts: %d; want: 2", len(app.opts)) } - if app.creds == nil { - t.Error("Credentials: nil; want creds") - } else if len(app.creds.JSON) == 0 { - t.Error("JSON: empty; want; non-empty") - } } func TestClientOptions(t *testing.T) { @@ -116,11 +111,6 @@ func TestRefreshTokenFile(t *testing.T) { if len(app.opts) != 2 { t.Errorf("Client opts: %d; want: 2", len(app.opts)) } - if app.creds == nil { - t.Error("Credentials: nil; want creds") - } else if len(app.creds.JSON) == 0 { - t.Error("JSON: empty; want; non-empty") - } } func TestRefreshTokenFileWithConfig(t *testing.T) { @@ -135,11 +125,6 @@ func TestRefreshTokenFileWithConfig(t *testing.T) { if len(app.opts) != 2 { t.Errorf("Client opts: %d; want: 2", len(app.opts)) } - if app.creds == nil { - t.Error("Credentials: nil; want creds") - } else if len(app.creds.JSON) == 0 { - t.Error("JSON: empty; want; non-empty") - } } func TestRefreshTokenWithEnvVar(t *testing.T) { @@ -158,11 +143,6 @@ func TestRefreshTokenWithEnvVar(t *testing.T) { if app.projectID != "mock-project-id" { t.Errorf("[env=%s] Project ID: %q; want: mock-project-id", varName, app.projectID) } - if app.creds == nil { - t.Errorf("[env=%s] Credentials: nil; want creds", varName) - } else if len(app.creds.JSON) == 0 { - t.Errorf("[env=%s] JSON: empty; want; non-empty", varName) - } } for _, varName := range []string{"GCLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT"} { verify(varName) @@ -185,11 +165,6 @@ func TestAppDefault(t *testing.T) { if len(app.opts) != 1 { t.Errorf("Client opts: %d; want: 1", len(app.opts)) } - if app.creds == nil { - t.Error("Credentials: nil; want creds") - } else if len(app.creds.JSON) == 0 { - t.Error("JSON: empty; want; non-empty") - } } func TestAppDefaultWithInvalidFile(t *testing.T) { @@ -201,8 +176,8 @@ func TestAppDefaultWithInvalidFile(t *testing.T) { defer os.Setenv(credEnvVar, current) app, err := NewApp(context.Background(), nil) - if app != nil || err == nil { - t.Errorf("NewApp() = (%v, %v); want: (nil, error)", app, err) + if app == nil || err != nil { + t.Fatalf("NewApp() = (%v, %v); want = (app, nil)", app, err) } } @@ -215,12 +190,20 @@ func TestInvalidCredentialFile(t *testing.T) { ctx := context.Background() for _, tc := range invalidFiles { app, err := NewApp(ctx, nil, option.WithCredentialsFile(tc)) - if app != nil || err == nil { - t.Errorf("NewApp(%q) = (%v, %v); want: (nil, error)", tc, app, err) + if app == nil || err != nil { + t.Fatalf("NewApp() = (%v, %v); want = (app, nil)", app, err) } } } +func TestExplicitNoAuth(t *testing.T) { + ctx := context.Background() + app, err := NewApp(ctx, nil, option.WithoutAuthentication()) + if app == nil || err != nil { + t.Fatalf("NewApp() = (%v, %v); want = (app, nil)", app, err) + } +} + func TestAuth(t *testing.T) { ctx := context.Background() app, err := NewApp(ctx, nil, option.WithCredentialsFile("testdata/service_account.json")) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index f62aa72a..24db0b40 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -249,6 +249,12 @@ func TestUnsubscribe(t *testing.T) { } } +func TestTopicSubscriptionInfoInvalidToken(t *testing.T) { + if _, err := client.TopicSubscriptionInfo(context.Background(), "INVALID_TOKEN"); err == nil || !messaging.IsInvalidArgument(err) { + t.Errorf("TopicSubscriptionInfo() = %v; want InvalidArgumentError", err) + } +} + func checkSuccessfulSendResponse(sr *messaging.SendResponse) error { if !sr.Success { return errors.New("Success = false; want = true") diff --git a/internal/internal.go b/internal/internal.go index d5164e74..8edc7e4d 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -20,7 +20,6 @@ import ( "time" "golang.org/x/oauth2" - "golang.org/x/oauth2/google" "google.golang.org/api/option" ) @@ -40,7 +39,6 @@ var SystemClock = &systemClock{} // AuthConfig represents the configuration of Firebase Auth service. type AuthConfig struct { Opts []option.ClientOption - Creds *google.DefaultCredentials ProjectID string ServiceAccountID string Version string diff --git a/messaging/messaging.go b/messaging/messaging.go index 37f61b00..2eef186d 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -901,6 +901,7 @@ type ErrorInfo struct { // Client is the interface for the Firebase Cloud Messaging (FCM) service. type Client struct { *fcmClient + *iidInfoClient *iidClient } @@ -919,8 +920,9 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error } return &Client{ - fcmClient: newFCMClient(hc, c), - iidClient: newIIDClient(hc), + fcmClient: newFCMClient(hc, c), + iidInfoClient: newIIDInfoClient(hc), + iidClient: newIIDClient(hc), }, nil } diff --git a/messaging/messaging_batch.go b/messaging/messaging_batch.go index 9ada0f0e..cb4e848a 100644 --- a/messaging/messaging_batch.go +++ b/messaging/messaging_batch.go @@ -38,7 +38,7 @@ const multipartBoundary = "__END_OF_PART__" // Messaging (FCM). // // It contains payload information as well as the list of device registration tokens to which the -// message should be sent. A single MulticastMessage may contain up to 100 registration tokens. +// message should be sent. A single MulticastMessage may contain up to 500 registration tokens. type MulticastMessage struct { Tokens []string Data map[string]string @@ -89,7 +89,7 @@ type BatchResponse struct { // SendAll sends the messages in the given array via Firebase Cloud Messaging. // -// The messages array may contain up to 100 messages. SendAll employs batching to send the entire +// The messages array may contain up to 500 messages. SendAll employs batching to send the entire // array of mssages as a single RPC call. Compared to the `Send()` function, // this is a significantly more efficient way to send multiple messages. The responses list // obtained from the return value corresponds to the order of the input messages. An error from @@ -105,7 +105,7 @@ func (c *fcmClient) SendAll(ctx context.Context, messages []*Message) (*BatchRes // This function does not actually deliver any messages to target devices. Instead, it performs all // the SDK-level and backend validations on the messages, and emulates the send operation. // -// The messages array may contain up to 100 messages. SendAllDryRun employs batching to send the +// The messages array may contain up to 500 messages. SendAllDryRun employs batching to send the // entire array of mssages as a single RPC call. Compared to the `SendDryRun()` function, this // is a significantly more efficient way to validate sending multiple messages. The responses list // obtained from the return value corresponds to the order of the input messages. An error from @@ -117,7 +117,7 @@ func (c *fcmClient) SendAllDryRun(ctx context.Context, messages []*Message) (*Ba // SendMulticast sends the given multicast message to all the FCM registration tokens specified. // -// The tokens array in MulticastMessage may contain up to 100 tokens. SendMulticast uses the +// The tokens array in MulticastMessage may contain up to 500 tokens. SendMulticast uses the // `SendAll()` function to send the given message to all the target recipients. The // responses list obtained from the return value corresponds to the order of the input tokens. An // error from SendMulticast indicates a total failure -- i.e. the message could not be sent to any @@ -137,7 +137,7 @@ func (c *fcmClient) SendMulticast(ctx context.Context, message *MulticastMessage // This function does not actually deliver any messages to target devices. Instead, it performs all // the SDK-level and backend validations on the messages, and emulates the send operation. // -// The tokens array in MulticastMessage may contain up to 100 tokens. SendMulticastDryRun uses the +// The tokens array in MulticastMessage may contain up to 500 tokens. SendMulticastDryRun uses the // `SendAllDryRun()` function to send the given message. The responses list obtained from // the return value corresponds to the order of the input tokens. An error from SendMulticastDryRun // indicates a total failure -- i.e. none of the messages were sent to FCM for validation. Partial diff --git a/messaging/topic_mgt.go b/messaging/topic_mgt.go index 8ae170a5..71c45d0b 100644 --- a/messaging/topic_mgt.go +++ b/messaging/topic_mgt.go @@ -20,14 +20,17 @@ import ( "fmt" "net/http" "strings" + "time" "firebase.google.com/go/internal" ) const ( - iidEndpoint = "https://iid.googleapis.com/iid/v1" - iidSubscribe = ":batchAdd" - iidUnsubscribe = ":batchRemove" + iidBaseEndpoint = "https://iid.googleapis.com/iid" + iidEndpoint = iidBaseEndpoint + "/v1" + iidInfoEndpoint = iidBaseEndpoint + "/info" + iidSubscribe = "batchAdd" + iidUnsubscribe = "batchRemove" ) var iidErrorCodes = map[string]struct{ Code, Msg string }{ @@ -84,6 +87,37 @@ func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { return tmr } +// TopicSubscriptionInfoResponse is the result produced by querying the app instance with a token. +// +// TopicSubscriptionInfoResponse contains topic subscription information associated with the input +// token; in particular for each topic the date when that topic was associated with the token is +// provided. +type TopicSubscriptionInfoResponse struct { + TopicMap map[string]*TopicInfo // TopicMap key is the topic name +} + +// TopicInfo is a topic detail information. +type TopicInfo struct { + Name string + AddDate time.Time +} + +type iidInfoClient struct { + iidInfoEndpoint string + httpClient *internal.HTTPClient +} + +func newIIDInfoClient(hc *http.Client) *iidInfoClient { + client := internal.WithDefaultRetryConfig(hc) + client.CreateErrFn = handleIIDInfoError + client.SuccessFn = internal.HasSuccessStatus + client.Opts = []internal.HTTPOption{internal.WithHeader("access_token_auth", "true"), internal.WithQueryParam("details", "true")} + return &iidInfoClient{ + iidInfoEndpoint: iidInfoEndpoint, + httpClient: client, + } +} + type iidClient struct { iidEndpoint string httpClient *internal.HTTPClient @@ -100,6 +134,34 @@ func newIIDClient(hc *http.Client) *iidClient { } } +// TopicSubscriptionInfo returns a list of topic subscriptions associated with the provided token. +func (c *iidInfoClient) TopicSubscriptionInfo(ctx context.Context, token string) (*TopicSubscriptionInfoResponse, error) { + if token == "" { + return nil, fmt.Errorf("no token specified") + } + + request := &internal.Request{ + Method: http.MethodGet, + URL: fmt.Sprintf("%s/%s", c.iidInfoEndpoint, token), + } + var result iidInfoResponse + if _, err := c.httpClient.DoAndUnmarshal(ctx, request, &result); err != nil { + return nil, err + } + + tsir := &TopicSubscriptionInfoResponse{} + tsir.TopicMap = make(map[string]*TopicInfo) + if result.Rel != nil && result.Rel.Topics != nil { + for k, v := range *result.Rel.Topics { + tsir.TopicMap[k] = &TopicInfo{ + Name: k, + AddDate: v.AddDate, + } + } + } + return tsir, nil +} + // SubscribeToTopic subscribes a list of registration tokens to a topic. // // The tokens list must not be empty, and have at most 1000 tokens. @@ -164,7 +226,7 @@ func (c *iidClient) makeTopicManagementRequest(ctx context.Context, req *iidRequ request := &internal.Request{ Method: http.MethodPost, - URL: fmt.Sprintf("%s/%s", c.iidEndpoint, req.op), + URL: fmt.Sprintf("%s:%s", c.iidEndpoint, req.op), Body: internal.NewJSONEntity(req), } var result iidResponse @@ -188,3 +250,43 @@ func handleIIDError(resp *internal.Response) error { } return internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg) } + +type iidInfoResponse struct { + Rel *iidInfoRel `json:"rel,omitempty"` +} + +type iidInfoRel struct { + Topics *map[string]iidTopicAddDateInfo `json:"topics,omitempty"` +} + +type iidTopicAddDateInfo struct { + AddDate time.Time `json:"-"` +} + +// UnmarshalJSON unmarshals a JSON string into a iidTopicAddDateInfo (for internal use only) +func (i *iidTopicAddDateInfo) UnmarshalJSON(b []byte) error { + type iidTopicAddDateInfoInternal iidTopicAddDateInfo + s := struct { + AddDateString string `json:"addDate"` + *iidTopicAddDateInfoInternal + }{ + iidTopicAddDateInfoInternal: (*iidTopicAddDateInfoInternal)(i), + } + var err error + if err = json.Unmarshal(b, &s); err != nil { + return err + } + if i.AddDate, err = time.Parse("2006-01-02", s.AddDateString); err != nil { + return fmt.Errorf("invalid date: %q", s.AddDateString) + } + return nil +} + +func handleIIDInfoError(resp *internal.Response) error { + var ie iidError + json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level + if resp.Status == http.StatusBadRequest { + return internal.Errorf(invalidArgument, "request contains an invalid argument; reason: %s", ie.Error) + } + return internal.Errorf(unknownError, "client encountered an unknown error; response: %s", string(resp.Body)) +} diff --git a/messaging/topic_mgt_test.go b/messaging/topic_mgt_test.go index 00be6755..1e0708af 100644 --- a/messaging/topic_mgt_test.go +++ b/messaging/topic_mgt_test.go @@ -41,7 +41,7 @@ func TestSubscribe(t *testing.T) { if err != nil { t.Fatal(err) } - client.iidEndpoint = ts.URL + client.iidEndpoint = ts.URL + "/v1" resp, err := client.SubscribeToTopic(ctx, []string{"id1", "id2"}, "test-topic") if err != nil { @@ -84,7 +84,7 @@ func TestUnsubscribe(t *testing.T) { if err != nil { t.Fatal(err) } - client.iidEndpoint = ts.URL + client.iidEndpoint = ts.URL + "/v1" resp, err := client.UnsubscribeFromTopic(ctx, []string{"id1", "id2"}, "test-topic") if err != nil { @@ -125,7 +125,7 @@ func TestTopicManagementError(t *testing.T) { if err != nil { t.Fatal(err) } - client.iidEndpoint = ts.URL + client.iidEndpoint = ts.URL + "/v1" client.iidClient.httpClient.RetryConfig = nil cases := []struct { @@ -185,7 +185,7 @@ func checkIIDRequest(t *testing.T, b []byte, tr *http.Request, op string) { if tr.Method != http.MethodPost { t.Errorf("Method = %q; want = %q", tr.Method, http.MethodPost) } - wantOp := "/" + op + wantOp := "/v1:" + op if tr.URL.Path != wantOp { t.Errorf("Path = %q; want = %q", tr.URL.Path, wantOp) } @@ -247,3 +247,144 @@ var invalidTopicMgtArgs = []struct { want: "tokens list must not contain empty strings", }, } + +func TestTopicSubscriptionInfo(t *testing.T) { + var tr *http.Request + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "appSigner": "sampleAppSigner", + "application": "sample.app", + "applicationVersion": "42", + "authorizedEntity": "42", + "platform": "ANDROID", + "rel": { + "topics": { + "test-topic1": { + "addDate": "2019-01-01" + }, + "test-topic2": { + "addDate": "2020-04-15" + } + } + }, + "scope": "*" + }`)) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidInfoEndpoint = ts.URL + client.iidInfoClient.httpClient.RetryConfig = nil + + resp, err := client.TopicSubscriptionInfo(ctx, "test-token") + if err != nil { + t.Fatal(err) + } + + if tr == nil { + t.Fatalf("Request = nil; want non-nil") + } + if tr.Method != http.MethodGet { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodGet) + } + if tr.URL.Path != "/test-token" { + t.Errorf("Path = %q; want = %q", tr.URL.Path, "/test-token") + } + if tr.URL.Query().Get("details") != "true" { + t.Errorf("Query param details = %q; want = %q", tr.URL.Query().Get("details"), "true") + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } + + if len(resp.TopicMap) != 2 { + t.Errorf("TopicMap length = %d; want = %d", len(resp.TopicMap), 2) + } + + if topic1, ok := resp.TopicMap["test-topic1"]; !ok { + t.Errorf("TopicMap key missing; want = %q", "test-topic1") + } else { + if topic1.Name != "test-topic1" { + t.Errorf("TopicMap Name = %q; want = %q", topic1.Name, "test-topic1") + } + t1DateStr := topic1.AddDate.Format("2006-01-02") + if t1DateStr != "2019-01-01" { + t.Errorf("TopicMap Date = %q; want = %q", t1DateStr, "2019-01-01") + } + } + + if topic2, ok := resp.TopicMap["test-topic2"]; !ok { + t.Errorf("TopicMap key missing; want = %q", "test-topic2") + } else { + if topic2.Name != "test-topic2" { + t.Errorf("TopicMap Name = %q; want = %q", topic2.Name, "test-topic2") + } + t2DateStr := topic2.AddDate.Format("2006-01-02") + if t2DateStr != "2020-04-15" { + t.Errorf("TopicMap Date = %q; want = %q", t2DateStr, "2020-04-15") + } + } +} + +func TestTopicSubscriptionInfoEmptyToken(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + + if _, err := client.TopicSubscriptionInfo(ctx, ""); err == nil { + t.Errorf("TopicSubscriptionInfo(empty) = nil; want error") + } + +} + +func TestTopicSubscriptionInfoError(t *testing.T) { + var tr *http.Request + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"error\": \"InvalidToken\"}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidInfoEndpoint = ts.URL + client.iidInfoClient.httpClient.RetryConfig = nil + + _, err = client.TopicSubscriptionInfo(ctx, "bad-token") + if err == nil { + t.Errorf("TopicSubscriptionInfo(bad) = nil; want error") + } else { + if tr == nil { + t.Fatalf("Request = nil; want non-nil") + } + if tr.Method != http.MethodGet { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodGet) + } + if tr.URL.Path != "/bad-token" { + t.Errorf("Path = %q; want = %q", tr.URL.Path, "/bad-token") + } + if tr.URL.Query().Get("details") != "true" { + t.Errorf("Query param details = %q; want = %q", tr.URL.Query().Get("details"), "true") + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } + + if !IsInvalidArgument(err) { + t.Errorf("TopicSubscriptionInfo(bad) = %v; want InvalidArgument error", err) + } + } +}