diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg index 7740dccd..5a52805c 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/workflows/ci.yml b/.github/workflows/ci.yml index 70970b34..f4f1ac58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: ${{ matrix.go }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b5b7665e..44b94950 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.23' @@ -53,7 +53,7 @@ jobs: - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + 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 }} @@ -68,7 +68,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b32ee10..533b4e0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,16 +36,12 @@ jobs: 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: Check out code - uses: actions/checkout@v4 - with: - ref: ${{ github.event.client_payload.ref || github.ref }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.23' @@ -71,57 +67,34 @@ jobs: # 3. with the label 'release:publish', and # 4. the title prefix '[chore] Release '. if: github.event.pull_request.merged && - github.ref == 'refs/heads/dev' && + github.base_ref == 'dev' && contains(github.event.pull_request.labels.*.name, 'release:publish') && startsWith(github.event.pull_request.title, '[chore] Release ') runs-on: ubuntu-latest permissions: - contents: write + pull-requests: write steps: - name: Checkout source for publish - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: + ref: dev persist-credentials: false - name: Publish preflight check id: preflight run: ./.github/scripts/publish_preflight_check.sh - # We authorize this step with an access token that has write access to the master branch. - - name: Merge to master - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.FIREBASE_GITHUB_TOKEN }} - script: | - github.rest.repos.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - base: 'master', - head: 'dev' - }) - - # See: https://cli.github.com/manual/gh_release_create - - name: Create release tag + # Create a PR to merge dev into master. + - name: Create Release PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create ${{ steps.preflight.outputs.version }} - --title "Firebase Admin Go SDK ${{ steps.preflight.outputs.version }}" - --notes '${{ steps.preflight.outputs.changelog }}' - --target "master" - - # 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 Go SDK is available. - https://github.com/firebase/firebase-admin-go/releases/tag/${{ steps.preflight.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 + RELEASE_BODY: ${{ steps.preflight.outputs.changelog }} + RELEASE_TITLE: "[chore] Release ${{ steps.preflight.outputs.version }}" + run: | + gh pr create \ + --base master \ + --head dev \ + --title "$RELEASE_TITLE" \ + --body "$RELEASE_BODY" diff --git a/.github/workflows/tag_release.yml b/.github/workflows/tag_release.yml new file mode 100644 index 00000000..a209e704 --- /dev/null +++ b/.github/workflows/tag_release.yml @@ -0,0 +1,49 @@ +# Copyright 2026 Google LLC +# +# 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: Tag Release + +on: + push: + branches: + - master + paths: + - 'firebase.go' + +jobs: + tag_release: + if: startsWith(github.event.head_commit.message, '[chore] Release ') + + runs-on: ubuntu-latest + environment: Release + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Publish preflight check + id: preflight + run: ./.github/scripts/publish_preflight_check.sh + + - name: Create release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VER: ${{ steps.preflight.outputs.version }} + RELEASE_NOTES: ${{ steps.preflight.outputs.changelog }} + run: gh release create "$RELEASE_VER" \ + --title "Firebase Admin Go SDK $RELEASE_VER" \ + --notes "$RELEASE_NOTES" \ + --target "master" diff --git a/AGENTS.md b/AGENTS.md index e6b95039..b2d6d89a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ The Firebase Admin Go SDK enables server-side (backend) applications to interact - **DO:** Use the centralized HTTP client in `internal/http_client.go` for all network calls. - **DO:** Pass `context.Context` as the first argument to all functions that perform I/O or other blocking operations. +- **DO:** Run `go fmt` after implementing a change and fix any linting errors. - **DON'T:** Expose types or functions from the `internal/` directory in the public API. - **DON'T:** Introduce new third-party dependencies without a strong, documented justification and team consensus. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eacfcda4..901fad26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,8 +148,12 @@ Set up your Firebase project as follows: 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. + 2. Click on the **Create database** button and create a default database. You can choose + to set up Firestore either in the production mode or in the test mode. + > **Note:** Integration tests are run against both the default database and an additional + database named "testing-database". + 3. After the default database is created, click the **Add database** button to create a + second database named "testing-database". 3. Enable Realtime Database: diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go index e411793c..539d24e5 100644 --- a/auth/tenant_mgt_test.go +++ b/auth/tenant_mgt_test.go @@ -90,6 +90,35 @@ func TestTenantGetUser(t *testing.T) { } } +func TestTenantQueryUsers(t *testing.T) { + resp := `{ + "usersInfo": [], + "recordsCount": "0" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + tenantClient, err := s.Client.TenantManager.AuthForTenant("test-tenant") + if err != nil { + t.Fatalf("Failed to create tenant client: %v", err) + } + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + } + + _, err = tenantClient.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() with tenant client = %v", err) + } + + wantPath := "/projects/mock-project-id/tenants/test-tenant/accounts:query" + if s.Req[0].RequestURI != wantPath { + t.Errorf("QueryUsers() URL = %q; want = %q", s.Req[0].RequestURI, wantPath) + } +} + func TestTenantGetUserByEmail(t *testing.T) { s := echoServer(testGetUserResponse, t) defer s.Close() diff --git a/auth/user_mgt.go b/auth/user_mgt.go index 2ceede79..3f51974b 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -1048,6 +1048,178 @@ func (c *baseClient) GetUsers( return &GetUsersResult{userRecords, notFound}, nil } +// QueryUserInfoResponse is the response from the QueryUsers function. +type QueryUserInfoResponse struct { + Users []*UserRecord + Count int64 +} + +type queryUsersResponse struct { + Users []*userQueryResponse `json:"userInfo"` + Count int64 `json:"recordsCount,string,omitempty"` +} + +// Expression represents a query condition used to filter results. +// +// Specify only one of Email, PhoneNumber, or UID. If you specify more than one, +// only the first (in order of Email, PhoneNumber, then UID) is applied. +type Expression struct { + // Email is a case-insensitive string that the account's email must match. + Email string `json:"email,omitempty"` + // PhoneNumber is a string that the account's phone number must match. + PhoneNumber string `json:"phoneNumber,omitempty"` + // UID is a string that the account's local ID must match. + UID string `json:"userId,omitempty"` +} + +// QueryUsersRequest is the request structure for the QueryUsers function. +type QueryUsersRequest struct { + // ReturnUserInfo specifies whether to return user accounts that match the query. + // If set to false, only the count of matching accounts is returned. + // Defaults to true. + ReturnUserInfo *bool `json:"returnUserInfo,omitempty"` + // Limit is the maximum number of accounts to return with an upper limit of 500. + // Defaults to 500. This field is valid only when ReturnUserInfo is true. + Limit int64 `json:"limit,string,omitempty"` + // Offset is the number of accounts to skip from the beginning of matching records. + // This field is valid only when ReturnUserInfo is true. + Offset int64 `json:"offset,string,omitempty"` + // SortBy is the field to use for sorting user accounts. + SortBy SortBy `json:"-"` + // Order is the sort order for the query results. + Order Order `json:"-"` + // TenantID is the ID of the tenant to which the results are scoped. + TenantID string `json:"tenantId,omitempty"` + // Expression is a list of query conditions used to filter the results. + Expression []*Expression `json:"expression,omitempty"` +} + +// build builds the query request (for internal use only). +func (q *QueryUsersRequest) build() interface{} { + var sortBy string + if q.SortBy != sortByUnspecified { + sortBys := map[SortBy]string{ + UID: "USER_ID", + Name: "NAME", + CreatedAt: "CREATED_AT", + LastLoginAt: "LAST_LOGIN_AT", + UserEmail: "USER_EMAIL", + } + sortBy = sortBys[q.SortBy] + } + + var order string + if q.Order != orderUnspecified { + orders := map[Order]string{ + Asc: "ASC", + Desc: "DESC", + } + order = orders[q.Order] + } + + type queryUsersRequestInternal QueryUsersRequest + internal := (*queryUsersRequestInternal)(q) + if internal.ReturnUserInfo == nil { + t := true + internal.ReturnUserInfo = &t + } + + return &struct { + SortBy string `json:"sortBy,omitempty"` + Order string `json:"order,omitempty"` + *queryUsersRequestInternal + }{ + SortBy: sortBy, + Order: order, + queryUsersRequestInternal: internal, + } +} + +func (q *QueryUsersRequest) validate() error { + if q.Limit != 0 && (q.Limit < 1 || q.Limit > 500) { + return fmt.Errorf("limit must be between 1 and 500") + } + if q.Offset < 0 { + return fmt.Errorf("offset must be non-negative") + } + for _, exp := range q.Expression { + if exp.Email != "" { + if err := validateEmail(exp.Email); err != nil { + return err + } + } + if exp.PhoneNumber != "" { + if err := validatePhone(exp.PhoneNumber); err != nil { + return err + } + } + if exp.UID != "" { + if err := validateUID(exp.UID); err != nil { + return err + } + } + } + return nil +} + +// SortBy defines the fields available for sorting user accounts. +type SortBy int + +const ( + sortByUnspecified SortBy = iota + // UID sorts results by user ID. + UID + // Name sorts results by name. + Name + // CreatedAt sorts results by creation time. + CreatedAt + // LastLoginAt sorts results by the last login time. + LastLoginAt + // UserEmail sorts results by user email. + UserEmail +) + +// Order defines the sort order for query results. +type Order int + +const ( + orderUnspecified Order = iota + // Asc sorts results in ascending order. + Asc + // Desc sorts results in descending order. + Desc +) + +// QueryUsers queries for user accounts based on the provided query configuration. +func (c *baseClient) QueryUsers(ctx context.Context, query *QueryUsersRequest) (*QueryUserInfoResponse, error) { + if query == nil { + return nil, fmt.Errorf("query request must not be nil") + } + if err := query.validate(); err != nil { + return nil, err + } + + var parsed queryUsersResponse + _, err := c.post(ctx, "/accounts:query", query.build(), &parsed) + if err != nil { + return nil, err + } + + var userRecords []*UserRecord + for _, user := range parsed.Users { + userRecord, err := user.makeUserRecord() + if err != nil { + return nil, fmt.Errorf("error while parsing response: %w", err) + } + userRecords = append(userRecords, userRecord) + } + + return &QueryUserInfoResponse{ + Users: userRecords, + Count: parsed.Count, + }, nil +} + type userQueryResponse struct { UID string `json:"localId,omitempty"` DisplayName string `json:"displayName,omitempty"` diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 01d8734f..094716c2 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -1899,6 +1899,247 @@ func TestDeleteUsers(t *testing.T) { }) } +func TestQueryUsers(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "email": "testuser@example.com", + "phoneNumber": "+1234567890", + "emailVerified": true, + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "validSince": "1494364393", + "disabled": false, + "createdAt": "1234567890000", + "lastLoginAt": "1233211232000", + "customAttributes": "{\"admin\": true, \"package\": \"gold\"}", + "tenantId": "testTenant", + "providerUserInfo": [{ + "providerId": "password", + "displayName": "Test User", + "photoUrl": "http://www.example.com/testuser/photo.png", + "email": "testuser@example.com", + "rawId": "testuid" + }, { + "providerId": "phone", + "phoneNumber": "+1234567890", + "rawId": "testuid" + }], + "mfaInfo": [{ + "phoneInfo": "+1234567890", + "mfaEnrollmentId": "enrolledPhoneFactor", + "displayName": "My MFA Phone", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }, { + "totpInfo": {}, + "mfaEnrollmentId": "enrolledTOTPFactor", + "displayName": "My MFA TOTP", + "enrolledAt": "2021-03-03T13:06:20.542896Z" + }] + }], + "recordsCount": "1" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + Limit: 1, + SortBy: UserEmail, + Order: Asc, + Expression: []*Expression{ + { + Email: "testuser@example.com", + }, + }, + } + + result, err := s.Client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + + if len(result.Users) != 1 { + t.Fatalf("QueryUsers() returned %d users; want 1", len(result.Users)) + } + + if result.Count != 1 { + t.Errorf("QueryUsers() returned count %d; want 1", result.Count) + } + + if !reflect.DeepEqual(result.Users[0], testUser) { + t.Errorf("QueryUsers() = %#v; want = %#v", result.Users[0], testUser) + } + + wantPath := "/projects/mock-project-id/accounts:query" + if s.Req[0].RequestURI != wantPath { + t.Errorf("QueryUsers() URL = %q; want = %q", s.Req[0].RequestURI, wantPath) + } +} + +func TestQueryUsersError(t *testing.T) { + resp := `{ + "error": { + "message": "INVALID_QUERY" + } + }` + s := echoServer([]byte(resp), t) + defer s.Close() + s.Status = http.StatusBadRequest + + returnUserInfo := true + query := &QueryUsersRequest{ + ReturnUserInfo: &returnUserInfo, + Limit: 1, + SortBy: UserEmail, + Order: Asc, + Expression: []*Expression{ + { + Email: "testuser@example.com", + }, + }, + } + + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersNilQuery(t *testing.T) { + s := echoServer([]byte("{}"), t) + defer s.Close() + result, err := s.Client.QueryUsers(context.Background(), nil) + if result != nil || err == nil { + t.Fatalf("QueryUsers(nil) = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersMalformedCustomAttributes(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "customAttributes": "invalid-json" + }] + }` + s := echoServer([]byte(resp), t) + defer s.Close() + query := &QueryUsersRequest{} + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersMalformedLastRefreshTimestamp(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser", + "lastRefreshAt": "invalid-timestamp" + }] + }` + s := echoServer([]byte(resp), t) + defer s.Close() + query := &QueryUsersRequest{} + result, err := s.Client.QueryUsers(context.Background(), query) + if result != nil || err == nil { + t.Fatalf("QueryUsers() = (%v, %v); want = (nil, error)", result, err) + } +} + +func TestQueryUsersDefaultReturnUserInfo(t *testing.T) { + resp := `{ + "userInfo": [{ + "localId": "testuser" + }], + "recordsCount": "1" + }` + s := echoServer([]byte(resp), t) + defer s.Close() + + // ReturnUserInfo is nil, should default to true in build() + query := &QueryUsersRequest{ + Limit: 1, + } + + _, err := s.Client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(s.Rbody, &got); err != nil { + t.Fatal(err) + } + + if got["returnUserInfo"] != true { + t.Errorf("QueryUsers() request[\"returnUserInfo\"] = %v; want true", got["returnUserInfo"]) + } +} + +func TestQueryUsersValidation(t *testing.T) { + s := echoServer([]byte("{}"), t) + defer s.Close() + + tests := []struct { + name string + query *QueryUsersRequest + }{ + { + name: "Invalid Limit Low", + query: &QueryUsersRequest{ + Limit: -1, + }, + }, + { + name: "Invalid Limit High", + query: &QueryUsersRequest{ + Limit: 501, + }, + }, + { + name: "Invalid Offset", + query: &QueryUsersRequest{ + Offset: -1, + }, + }, + { + name: "Invalid Email in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {Email: "invalid-email"}, + }, + }, + }, + { + name: "Invalid Phone in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {PhoneNumber: "invalid-phone"}, + }, + }, + }, + { + name: "Invalid UID in Expression", + query: &QueryUsersRequest{ + Expression: []*Expression{ + {UID: string(make([]byte, 129))}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.Client.QueryUsers(context.Background(), tt.query) + if err == nil { + t.Errorf("QueryUsers() with %s; want error, got nil", tt.name) + } + }) + } +} + func TestMakeExportedUser(t *testing.T) { queryResponse := &userQueryResponse{ UID: "testuser", diff --git a/firebase.go b/firebase.go index 9373ae23..d9709f98 100644 --- a/firebase.go +++ b/firebase.go @@ -40,7 +40,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.18.0" +const Version = "4.19.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" @@ -105,10 +105,16 @@ func (a *App) Storage(ctx context.Context) (*storage.Client, error) { // Firestore returns a new firestore.Client instance from the https://godoc.org/cloud.google.com/go/firestore // package. func (a *App) Firestore(ctx context.Context) (*firestore.Client, error) { + return a.FirestoreWithDatabaseID(ctx, firestore.DefaultDatabaseID) +} + +// FirestoreWithDatabaseID returns a new firestore.Client instance with the specified named database from the +// https://godoc.org/cloud.google.com/go/firestore package. +func (a *App) FirestoreWithDatabaseID(ctx context.Context, databaseID string) (*firestore.Client, error) { if a.projectID == "" { return nil, errors.New("project id is required to access Firestore") } - return firestore.NewClient(ctx, a.projectID, a.opts...) + return firestore.NewClientWithDatabase(ctx, a.projectID, databaseID, a.opts...) } // InstanceID returns an instance of iid.Client. diff --git a/firebase_test.go b/firebase_test.go index 2699146a..ddc130a1 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -287,6 +287,18 @@ func TestFirestore(t *testing.T) { } } +func TestFirestoreWithDatabaseID(t *testing.T) { + ctx := context.Background() + app, err := NewApp(ctx, nil, option.WithCredentialsFile("testdata/service_account.json")) + if err != nil { + t.Fatal(err) + } + + if c, err := app.FirestoreWithDatabaseID(ctx, "other-db"); c == nil || err != nil { + t.Errorf("FirestoreWithDatabaseID() = (%v, %v); want (client, nil)", c, err) + } +} + func TestFirestoreWithProjectID(t *testing.T) { verify := func(varName string) { current := os.Getenv(varName) @@ -336,6 +348,10 @@ func TestFirestoreWithNoProjectID(t *testing.T) { if c, err := app.Firestore(ctx); c != nil || err == nil { t.Errorf("Firestore() = (%v, %v); want (nil, error)", c, err) } + + if c, err := app.FirestoreWithDatabaseID(ctx, "other-db"); c != nil || err == nil { + t.Errorf("FirestoreWithDatabaseID() = (%v, %v); want (nil, error)", c, err) + } } func TestInstanceID(t *testing.T) { diff --git a/integration/auth/tenant_mgt_test.go b/integration/auth/tenant_mgt_test.go index a1349d4c..c5ab6de2 100644 --- a/integration/auth/tenant_mgt_test.go +++ b/integration/auth/tenant_mgt_test.go @@ -427,6 +427,23 @@ func testTenantAwareUserManagement(t *testing.T, id string) { } }) + t.Run("QueryUsers()", func(t *testing.T) { + query := &auth.QueryUsersRequest{ + Expression: []*auth.Expression{ + { + Email: want.Email, + }, + }, + } + result, err := tenantClient.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) != 1 || result.Users[0].UID != user.UID { + t.Errorf("QueryUsers(email=%s) = %v; want user %s", want.Email, result.Users, user.UID) + } + }) + t.Run("DeleteUser()", func(t *testing.T) { if err := tenantClient.DeleteUser(context.Background(), user.UID); err != nil { t.Fatalf("DeleteUser() = %v", err) diff --git a/integration/auth/user_mgt_test.go b/integration/auth/user_mgt_test.go index cae59097..399c3fe2 100644 --- a/integration/auth/user_mgt_test.go +++ b/integration/auth/user_mgt_test.go @@ -1442,3 +1442,39 @@ func deletePhoneNumberUser(t *testing.T, phoneNumber string) { t.Fatal(err) } } +func TestQueryUsers(t *testing.T) { + u1 := newUserWithParams(t) + defer deleteUser(u1.UID) + u2 := newUserWithParams(t) + defer deleteUser(u2.UID) + + // Query by email + query := &auth.QueryUsersRequest{ + Expression: []*auth.Expression{ + { + Email: u1.Email, + }, + }, + } + result, err := client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) != 1 || result.Users[0].UID != u1.UID { + t.Errorf("QueryUsers(uid=%s) = %v; want user %s", u1.UID, result.Users, u1.UID) + } + + // Query with limit and sort + query = &auth.QueryUsersRequest{ + Limit: 2, + SortBy: auth.CreatedAt, + Order: auth.Desc, + } + result, err = client.QueryUsers(context.Background(), query) + if err != nil { + t.Fatalf("QueryUsers() = %v", err) + } + if len(result.Users) < 2 { + t.Errorf("QueryUsers(limit=2) = %d users; want >= 2", len(result.Users)) + } +} diff --git a/integration/firestore/firestore_test.go b/integration/firestore/firestore_test.go index 3e6d2da9..3300f882 100644 --- a/integration/firestore/firestore_test.go +++ b/integration/firestore/firestore_test.go @@ -16,18 +16,42 @@ package firestore import ( "context" + "flag" "log" + "os" "reflect" "testing" "firebase.google.com/go/v4/integration/internal" ) -func TestFirestore(t *testing.T) { +const testDatabaseID = "testing-database" + +var ( + cityData = map[string]interface{}{ + "name": "Mountain View", + "country": "USA", + "population": int64(77846), + "capital": false, + } + movieData = map[string]interface{}{ + "Name": "Interstellar", + "Year": int64(2014), + "Runtime": "2h 49m", + "Academy Award Winner": true, + } +) + +func TestMain(m *testing.M) { + flag.Parse() if testing.Short() { log.Println("skipping Firestore integration tests in short mode.") - return + os.Exit(0) } + os.Exit(m.Run()) +} + +func TestFirestore(t *testing.T) { ctx := context.Background() app, err := internal.NewTestApp(ctx, nil) if err != nil { @@ -40,23 +64,93 @@ func TestFirestore(t *testing.T) { } doc := client.Collection("cities").Doc("Mountain View") - data := map[string]interface{}{ - "name": "Mountain View", - "country": "USA", - "population": int64(77846), - "capital": false, + if _, err := doc.Set(ctx, cityData); err != nil { + t.Fatal(err) + } + defer doc.Delete(ctx) + + snap, err := doc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(snap.Data(), cityData) { + t.Errorf("Get() = %v; want %v", snap.Data(), cityData) + } +} + +func TestFirestoreWithDatabaseID(t *testing.T) { + ctx := context.Background() + app, err := internal.NewTestApp(ctx, nil) + if err != nil { + t.Fatal(err) + } + + // This test requires the target non-default database to exist in the project. + // If it doesn't exist, this test will fail. + client, err := app.FirestoreWithDatabaseID(ctx, testDatabaseID) + if err != nil { + t.Fatal(err) } - if _, err := doc.Set(ctx, data); err != nil { + + doc := client.Collection("cities").NewDoc() + if _, err := doc.Set(ctx, cityData); err != nil { t.Fatal(err) } + defer doc.Delete(ctx) + snap, err := doc.Get(ctx) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(snap.Data(), data) { - t.Errorf("Get() = %v; want %v", snap.Data(), data) + if !reflect.DeepEqual(snap.Data(), cityData) { + t.Errorf("Get() = %v; want %v", snap.Data(), cityData) } - if _, err := doc.Delete(ctx); err != nil { +} + +func TestFirestoreMultiDB(t *testing.T) { + ctx := context.Background() + app, err := internal.NewTestApp(ctx, nil) + if err != nil { t.Fatal(err) } + + cityClient, err := app.Firestore(ctx) + if err != nil { + t.Fatal(err) + } + // This test requires the target non-default database to exist in the project. + // If it doesn't exist, this test will fail. + movieClient, err := app.FirestoreWithDatabaseID(ctx, testDatabaseID) + if err != nil { + t.Fatal(err) + } + + cityDoc := cityClient.Collection("cities").NewDoc() + movieDoc := movieClient.Collection("movies").NewDoc() + + if _, err := cityDoc.Set(ctx, cityData); err != nil { + t.Fatal(err) + } + defer cityDoc.Delete(ctx) + + if _, err := movieDoc.Set(ctx, movieData); err != nil { + t.Fatal(err) + } + defer movieDoc.Delete(ctx) + + citySnap, err := cityDoc.Get(ctx) + if err != nil { + t.Fatal(err) + } + movieSnap, err := movieDoc.Get(ctx) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(citySnap.Data(), cityData) { + t.Errorf("City Get() = %v; want %v", citySnap.Data(), cityData) + } + if !reflect.DeepEqual(movieSnap.Data(), movieData) { + t.Errorf("Movie Get() = %v; want %v", movieSnap.Data(), movieData) + } } diff --git a/messaging/messaging.go b/messaging/messaging.go index 2eda22a9..ed76aa68 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -39,15 +39,6 @@ const ( apiFormatVersionHeader = "X-GOOG-API-FORMAT-VERSION" apiFormatVersion = "2" - apnsAuthError = "APNS_AUTH_ERROR" - internalError = "INTERNAL" - thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR" - invalidArgument = "INVALID_ARGUMENT" - quotaExceeded = "QUOTA_EXCEEDED" - senderIDMismatch = "SENDER_ID_MISMATCH" - unregistered = "UNREGISTERED" - unavailable = "UNAVAILABLE" - rfc3339Zulu = "2006-01-02T15:04:05.000000000Z" ) @@ -994,99 +985,6 @@ func (c *fcmClient) makeSendRequest(ctx context.Context, req *fcmRequest) (strin return result.Name, err } -// IsInternal checks if the given error was due to an internal server error. -func IsInternal(err error) bool { - return hasMessagingErrorCode(err, internalError) -} - -// IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth -// key. -// -// Deprecated: Use IsThirdPartyAuthError(). -func IsInvalidAPNSCredentials(err error) bool { - return IsThirdPartyAuthError(err) -} - -// IsThirdPartyAuthError checks if the given error was due to invalid APNS certificate or auth -// key. -func IsThirdPartyAuthError(err error) bool { - return hasMessagingErrorCode(err, thirdPartyAuthError) || hasMessagingErrorCode(err, apnsAuthError) -} - -// IsInvalidArgument checks if the given error was due to an invalid argument in the request. -func IsInvalidArgument(err error) bool { - return hasMessagingErrorCode(err, invalidArgument) -} - -// IsMessageRateExceeded checks if the given error was due to the client exceeding a quota. -// -// Deprecated: Use IsQuotaExceeded(). -func IsMessageRateExceeded(err error) bool { - return IsQuotaExceeded(err) -} - -// IsQuotaExceeded checks if the given error was due to the client exceeding a quota. -func IsQuotaExceeded(err error) bool { - return hasMessagingErrorCode(err, quotaExceeded) -} - -// IsMismatchedCredential checks if the given error was due to an invalid credential or permission -// error. -// -// Deprecated: Use IsSenderIDMismatch(). -func IsMismatchedCredential(err error) bool { - return IsSenderIDMismatch(err) -} - -// IsSenderIDMismatch checks if the given error was due to an invalid credential or permission -// error. -func IsSenderIDMismatch(err error) bool { - return hasMessagingErrorCode(err, senderIDMismatch) -} - -// IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that -// became invalid. -// -// Deprecated: Use IsUnregistered(). -func IsRegistrationTokenNotRegistered(err error) bool { - return IsUnregistered(err) -} - -// IsUnregistered checks if the given error was due to a registration token that -// became invalid. -func IsUnregistered(err error) bool { - return hasMessagingErrorCode(err, unregistered) -} - -// IsServerUnavailable checks if the given error was due to the backend server being temporarily -// unavailable. -// -// Deprecated: Use IsUnavailable(). -func IsServerUnavailable(err error) bool { - return IsUnavailable(err) -} - -// IsUnavailable checks if the given error was due to the backend server being temporarily -// unavailable. -func IsUnavailable(err error) bool { - return hasMessagingErrorCode(err, unavailable) -} - -// IsTooManyTopics checks if the given error was due to the client exceeding the allowed number -// of topics. -// -// Deprecated: Always returns false. -func IsTooManyTopics(err error) bool { - return false -} - -// IsUnknown checks if the given error was due to unknown error returned by the backend server. -// -// Deprecated: Always returns false. -func IsUnknown(err error) bool { - return false -} - type fcmRequest struct { ValidateOnly bool `json:"validate_only,omitempty"` Message *Message `json:"message,omitempty"` @@ -1095,36 +993,3 @@ type fcmRequest struct { type fcmResponse struct { Name string `json:"name"` } - -type fcmErrorResponse struct { - Error struct { - Details []struct { - Type string `json:"@type"` - ErrorCode string `json:"errorCode"` - } - } `json:"error"` -} - -func handleFCMError(resp *internal.Response) error { - base := internal.NewFirebaseErrorOnePlatform(resp) - var fe fcmErrorResponse - json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level - for _, d := range fe.Error.Details { - if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" { - base.Ext["messagingErrorCode"] = d.ErrorCode - break - } - } - - return base -} - -func hasMessagingErrorCode(err error, code string) bool { - fe, ok := err.(*internal.FirebaseError) - if !ok { - return false - } - - got, ok := fe.Ext["messagingErrorCode"] - return ok && got == code -} diff --git a/messaging/messaging_errors.go b/messaging/messaging_errors.go new file mode 100644 index 00000000..0c43ad83 --- /dev/null +++ b/messaging/messaging_errors.go @@ -0,0 +1,228 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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. + +package messaging + +import ( + "encoding/json" + + "firebase.google.com/go/v4/errorutils" + "firebase.google.com/go/v4/internal" +) + +// FCM error codes +const ( + apnsAuthError = "APNS_AUTH_ERROR" + internalError = "INTERNAL" + thirdPartyAuthError = "THIRD_PARTY_AUTH_ERROR" + invalidArgument = "INVALID_ARGUMENT" + quotaExceeded = "QUOTA_EXCEEDED" + senderIDMismatch = "SENDER_ID_MISMATCH" + unregistered = "UNREGISTERED" + unavailable = "UNAVAILABLE" +) + +// QuotaViolation describes a single quota violation, identifying which quota +// was exceeded. +// See https://docs.cloud.google.com/tasks/docs/reference/rpc/google.rpc#google.rpc.QuotaFailure.Violation +// for more information on the google.rpc.QuotaFailure.Violation type. +type QuotaViolation struct { + // Subject is the subject on which the quota check failed. + // For example, "clientip:" or "project:". + Subject string + // Description explains how the quota check failed. + Description string + // APIService is the API service from which the QuotaFailure originates. + APIService string + // QuotaMetric is the metric of the violated quota (e.g., "compute.googleapis.com/cpus"). + QuotaMetric string + // QuotaID is the unique identifier of a quota (e.g., "CPUS-per-project-region"). + QuotaID string + // QuotaDimensions contains the dimensions of the violated quota. + QuotaDimensions map[string]string + // QuotaValue is the enforced quota value at the time of the failure. + QuotaValue int64 + // FutureQuotaValue is the new quota value being rolled out, if a rollout is in progress. + FutureQuotaValue int64 +} + +// QuotaFailure contains information about quota violations from FCM. +// This is returned when a rate limit is exceeded (device, topic, or overall). +type QuotaFailure struct { + Violations []*QuotaViolation +} + +// IsInternal checks if the given error was due to an internal server error. +func IsInternal(err error) bool { + return hasMessagingErrorCode(err, internalError) +} + +// IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth +// key. +// +// Deprecated: Use IsThirdPartyAuthError(). +func IsInvalidAPNSCredentials(err error) bool { + return IsThirdPartyAuthError(err) +} + +// IsThirdPartyAuthError checks if the given error was due to invalid APNS certificate or auth +// key. +func IsThirdPartyAuthError(err error) bool { + return hasMessagingErrorCode(err, thirdPartyAuthError) || hasMessagingErrorCode(err, apnsAuthError) +} + +// IsInvalidArgument checks if the given error was due to an invalid argument in the request. +func IsInvalidArgument(err error) bool { + return hasMessagingErrorCode(err, invalidArgument) +} + +// IsMessageRateExceeded checks if the given error was due to the client exceeding a quota. +// +// Deprecated: Use IsQuotaExceeded(). +func IsMessageRateExceeded(err error) bool { + return IsQuotaExceeded(err) +} + +// IsQuotaExceeded checks if the given error was due to the client exceeding a quota. +func IsQuotaExceeded(err error) bool { + return hasMessagingErrorCode(err, quotaExceeded) +} + + +// IsMismatchedCredential checks if the given error was due to an invalid credential or permission +// error. +// +// Deprecated: Use IsSenderIDMismatch(). +func IsMismatchedCredential(err error) bool { + return IsSenderIDMismatch(err) +} + +// IsSenderIDMismatch checks if the given error was due to an invalid credential or permission +// error. +func IsSenderIDMismatch(err error) bool { + return hasMessagingErrorCode(err, senderIDMismatch) +} + +// IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that +// became invalid. +// +// Deprecated: Use IsUnregistered(). +func IsRegistrationTokenNotRegistered(err error) bool { + return IsUnregistered(err) +} + +// IsUnregistered checks if the given error was due to a registration token that +// became invalid. +func IsUnregistered(err error) bool { + return hasMessagingErrorCode(err, unregistered) +} + +// IsServerUnavailable checks if the given error was due to the backend server being temporarily +// unavailable. +// +// Deprecated: Use IsUnavailable(). +func IsServerUnavailable(err error) bool { + return IsUnavailable(err) +} + +// IsUnavailable checks if the given error was due to the backend server being temporarily +// unavailable. +func IsUnavailable(err error) bool { + return hasMessagingErrorCode(err, unavailable) +} + +// IsTooManyTopics checks if the given error was due to the client exceeding the allowed number +// of topics. +// +// Deprecated: Always returns false. +func IsTooManyTopics(err error) bool { + return false +} + +// IsUnknown checks if the given error was due to unknown error returned by the backend server. +// +// Deprecated: Always returns false. +func IsUnknown(err error) bool { + return false +} + +type fcmErrorResponse struct { + Error struct { + Details []fcmErrorDetail `json:"details"` + } `json:"error"` +} + +type fcmQuotaViolation struct { + Subject string `json:"subject"` + Description string `json:"description"` + APIService string `json:"api_service"` + QuotaMetric string `json:"quota_metric"` + QuotaID string `json:"quota_id"` + QuotaDimensions map[string]string `json:"quota_dimensions"` + QuotaValue int64 `json:"quota_value"` + FutureQuotaValue int64 `json:"future_quota_value"` +} + +type fcmErrorDetail struct { + Type string `json:"@type"` + ErrorCode string `json:"errorCode"` + Violations []fcmQuotaViolation `json:"violations"` +} + +func handleFCMError(resp *internal.Response) error { + base := internal.NewFirebaseErrorOnePlatform(resp) + var fe fcmErrorResponse + json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level + + // FCM error responses include a "details" array with typed extensions. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + for _, d := range fe.Error.Details { + // FcmError extension contains the FCM-specific error code. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/FcmError + if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmError" { + base.Ext["messagingErrorCode"] = d.ErrorCode + } + // QuotaFailure extension is returned when QUOTA_EXCEEDED error occurs. + // See https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + // "An extension of type google.rpc.QuotaFailure is returned to specify which quota was exceeded." + if d.Type == "type.googleapis.com/google.rpc.QuotaFailure" && len(d.Violations) > 0 { + violations := make([]*QuotaViolation, len(d.Violations)) + for i, v := range d.Violations { + violations[i] = &QuotaViolation{ + Subject: v.Subject, + Description: v.Description, + APIService: v.APIService, + QuotaMetric: v.QuotaMetric, + QuotaID: v.QuotaID, + QuotaDimensions: v.QuotaDimensions, + QuotaValue: v.QuotaValue, + FutureQuotaValue: v.FutureQuotaValue, + } + } + base.Ext["quotaFailure"] = &QuotaFailure{Violations: violations} + } + } + + return base +} + +func hasMessagingErrorCode(err error, code string) bool { + fe, ok := err.(*internal.FirebaseError) + if !ok { + return false + } + + got, ok := fe.Ext["messagingErrorCode"] + return ok && got == code +} diff --git a/messaging/messaging_errors_test.go b/messaging/messaging_errors_test.go new file mode 100644 index 00000000..c1d1396f --- /dev/null +++ b/messaging/messaging_errors_test.go @@ -0,0 +1,208 @@ +// Copyright 2018 Google LLC All Rights Reserved. +// +// 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. + +package messaging + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "firebase.google.com/go/v4/errorutils" + "firebase.google.com/go/v4/internal" +) + +func TestGetQuotaFailureEdgeCases(t *testing.T) { + tests := []struct { + name string + err *errorutils.FirebaseError + }{ + {"nil error", nil}, + {"no ext", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Message: "test"}}, + {"empty ext", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Ext: map[string]interface{}{}}}, + {"wrong type", &errorutils.FirebaseError{ErrorCode: internal.Unknown, Ext: map[string]interface{}{"quotaFailure": "string"}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if qf := GetQuotaFailure(tc.err); qf != nil { + t.Errorf("GetQuotaFailure() = %v; want nil", qf) + } + }) + } +} + +// TestQuotaFailureParsing verifies all QuotaViolation fields are parsed correctly from FCM responses. +func TestQuotaFailureParsing(t *testing.T) { + resp := `{ + "error": { + "status": "RESOURCE_EXHAUSTED", + "message": "Quota exceeded", + "details": [ + {"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "QUOTA_EXCEEDED"}, + { + "@type": "type.googleapis.com/google.rpc.QuotaFailure", + "violations": [ + { + "subject": "project:test-project", + "description": "Device message rate exceeded", + "api_service": "firebasecloudmessaging.googleapis.com", + "quota_metric": "firebasecloudmessaging.googleapis.com/device_messages", + "quota_id": "DeviceMessagesPerMinute", + "quota_dimensions": {"device_token": "abc123"}, + "quota_value": 100, + "future_quota_value": 200 + }, + {"subject": "device:token123", "description": "Second violation"} + ] + } + ] + } + }` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + client, err := NewClient(context.Background(), testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + client.fcmClient.httpClient.RetryConfig = nil + + _, sendErr := client.Send(context.Background(), &Message{Topic: "topic"}) + if sendErr == nil { + t.Fatal("Send() = nil; want error") + } + + // Verify error type checks work + if !IsQuotaExceeded(sendErr) { + t.Error("IsQuotaExceeded() = false; want true") + } + if !IsMessageRateExceeded(sendErr) { // deprecated alias + t.Error("IsMessageRateExceeded() = false; want true") + } + + fe := sendErr.(*errorutils.FirebaseError) + qf := GetQuotaFailure(fe) + if qf == nil { + t.Fatal("GetQuotaFailure() = nil; want non-nil") + } + if len(qf.Violations) != 2 { + t.Fatalf("len(Violations) = %d; want 2", len(qf.Violations)) + } + + // Verify all fields on first violation + v := qf.Violations[0] + if v.Subject != "project:test-project" { + t.Errorf("Subject = %q; want %q", v.Subject, "project:test-project") + } + if v.APIService != "firebasecloudmessaging.googleapis.com" { + t.Errorf("APIService = %q; want %q", v.APIService, "firebasecloudmessaging.googleapis.com") + } + if v.QuotaMetric != "firebasecloudmessaging.googleapis.com/device_messages" { + t.Errorf("QuotaMetric = %q; want correct value", v.QuotaMetric) + } + if v.QuotaID != "DeviceMessagesPerMinute" { + t.Errorf("QuotaID = %q; want %q", v.QuotaID, "DeviceMessagesPerMinute") + } + if v.QuotaDimensions == nil || v.QuotaDimensions["device_token"] != "abc123" { + t.Errorf("QuotaDimensions = %v; want map with device_token=abc123", v.QuotaDimensions) + } + if v.QuotaValue != 100 || v.FutureQuotaValue != 200 { + t.Errorf("QuotaValue=%d, FutureQuotaValue=%d; want 100, 200", v.QuotaValue, v.FutureQuotaValue) + } + + // Verify second violation + if qf.Violations[1].Subject != "device:token123" { + t.Errorf("Violations[1].Subject = %q; want %q", qf.Violations[1].Subject, "device:token123") + } +} + +// TestDeprecatedErrorFunctions verifies deprecated functions still work for backwards compatibility. +func TestDeprecatedErrorFunctions(t *testing.T) { + tests := []struct { + name string + httpStatus int + resp string + deprecated func(error) bool + current func(error) bool + }{ + { + name: "IsInvalidAPNSCredentials", + httpStatus: http.StatusUnauthorized, + resp: `{"error": {"status": "UNAUTHENTICATED", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "THIRD_PARTY_AUTH_ERROR"}]}}`, + deprecated: IsInvalidAPNSCredentials, + current: IsThirdPartyAuthError, + }, + { + name: "IsMismatchedCredential", + httpStatus: http.StatusForbidden, + resp: `{"error": {"status": "PERMISSION_DENIED", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "SENDER_ID_MISMATCH"}]}}`, + deprecated: IsMismatchedCredential, + current: IsSenderIDMismatch, + }, + { + name: "IsRegistrationTokenNotRegistered", + httpStatus: http.StatusNotFound, + resp: `{"error": {"status": "NOT_FOUND", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED"}]}}`, + deprecated: IsRegistrationTokenNotRegistered, + current: IsUnregistered, + }, + { + name: "IsServerUnavailable", + httpStatus: http.StatusServiceUnavailable, + resp: `{"error": {"status": "UNAVAILABLE", "message": "test", "details": [{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNAVAILABLE"}]}}`, + deprecated: IsServerUnavailable, + current: IsUnavailable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.httpStatus) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(tc.resp)) + })) + defer ts.Close() + + client, _ := NewClient(context.Background(), testMessagingConfig) + client.fcmEndpoint = ts.URL + client.fcmClient.httpClient.RetryConfig = nil + + _, err := client.Send(context.Background(), &Message{Topic: "topic"}) + if !tc.deprecated(err) { + t.Errorf("%s() = false; want true", tc.name) + } + if !tc.current(err) { + t.Errorf("current function = false; want true") + } + }) + } +} + +// TestDeprecatedFunctionsAlwaysFalse verifies IsTooManyTopics and IsUnknown always return false. +func TestDeprecatedFunctionsAlwaysFalse(t *testing.T) { + if IsTooManyTopics(nil) { + t.Error("IsTooManyTopics(nil) = true; want false") + } + if IsUnknown(nil) { + t.Error("IsUnknown(nil) = true; want false") + } +}