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/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 bee027e3..3b80e4b4 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 structure for the QueryUsers function. +type QueryUserInfoResponse struct { + Users []*UserRecord + Count int64 +} + +type queryUsersResponse struct { + Users []*userQueryResponse `json:"userInfo"` + Count int64 `json:"recordsCount,string,omitempty"` +} + +// Expression is a query condition used to filter results. +// +// Only one of Email, PhoneNumber, or UID should be specified. +// If more than one is specified, only the first (in the order of Email, PhoneNumber, UID) will be applied. +type Expression struct { + // Email is a case insensitive string that the account's email should match. + Email string `json:"email,omitempty"` + // PhoneNumber is a string that the account's phone number should match. + PhoneNumber string `json:"phoneNumber,omitempty"` + // UID is a string that the account's local ID should match. + UID string `json:"userId,omitempty"` +} + +// QueryUsersRequest is the request structure for the QueryUsers function. +type QueryUsersRequest struct { + // ReturnUserInfo indicates whether to return the accounts matching the query. + // If false, only the count of accounts matching the query will be 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. Only valid when ReturnUserInfo is set to true. + Limit int64 `json:"limit,string,omitempty"` + // Offset is the number of accounts to skip from the beginning of matching records. + // Only valid when ReturnUserInfo is set to true. + Offset int64 `json:"offset,string,omitempty"` + // SortBy is the field to use for sorting user accounts. + SortBy SortBy `json:"-"` + // Order is the order for sorting query results. + Order Order `json:"-"` + // TenantID is the ID of the tenant to which the result is scoped. + TenantID string `json:"tenantId,omitempty"` + // Expression is a list of query conditions used to filter 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 is a field to use for sorting user accounts. +type SortBy int + +const ( + sortByUnspecified SortBy = iota + // UID sorts results by userId. + UID + // Name sorts results by name. + Name + // CreatedAt sorts results by createdAt. + CreatedAt + // LastLoginAt sorts results by lastLoginAt. + LastLoginAt + // UserEmail sorts results by userEmail. + UserEmail +) + +// Order is an order for sorting query results. +type Order int + +const ( + orderUnspecified Order = iota + // Asc sorts in ascending order. + Asc + // Desc sorts 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/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)) + } +}