From 524d7d81afbdab7c7f5a7c327763df792de8c78c Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Wed, 17 Sep 2025 14:59:09 -0700 Subject: [PATCH 01/20] add in changes from stephenarosaj/fdc-impersonate --- .../data-connect-api-client-internal.ts | 229 ++++++++++++++---- src/data-connect/data-connect-api.ts | 11 +- src/data-connect/data-connect.ts | 42 +++- src/data-connect/index.ts | 1 + 4 files changed, 232 insertions(+), 51 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 3ef99bacea..56aa2e94c3 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -27,17 +27,31 @@ import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data- const API_VERSION = 'v1'; -/** The Firebase Data Connect backend base URL format. */ -const FIREBASE_DATA_CONNECT_BASE_URL_FORMAT = - 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; - -/** Firebase Data Connect base URl format when using the Data Connect emultor. */ -const FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT = +// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +// TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE +// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/** The Firebase Data Connect backend service URL format. */ +const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT = + 'https://autopush-firebasedataconnect.sandbox.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; + +/** The Firebase Data Connect backend connector URL format. */ +const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT = + 'https://autopush-firebasedataconnect.sandbox.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}'; + +/** Firebase Data Connect service URL format when using the Data Connect emulator. */ +const FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT = 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; +/** Firebase Data Connect connector URL format when using the Data Connect emulator. */ +const FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT = + 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}'; + const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql'; const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead'; +const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery'; +const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation'; + const DATA_CONNECT_CONFIG_HEADERS = { 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` }; @@ -89,6 +103,15 @@ export class DataConnectApiClient { return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options); } + + /** + * A helper function to execute GraphQL queries. + * + * @param query - The arbitrary GraphQL query to execute. + * @param endpoint - The endpoint to call. + * @param options - The GraphQL options. + * @returns A promise that fulfills with the GraphQL response, or throws an error. + */ private async executeGraphqlHelper( query: string, endpoint: string, @@ -112,24 +135,8 @@ export class DataConnectApiClient { ...(options?.operationName && { operationName: options?.operationName }), ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; - return this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint) - .then(async (url) => { - const request: HttpRequestConfig = { - method: 'POST', - url, - headers: DATA_CONNECT_CONFIG_HEADERS, - data, - }; - const resp = await this.httpClient.send(request); - if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) { - const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' '); - throw new FirebaseDataConnectError( - DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages); - } - return Promise.resolve({ - data: resp.data.data as GraphqlResponse, - }); - }) + const url = await this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint); + return this.makeGqlRequest(url, data) .then((resp) => { return resp; }) @@ -138,28 +145,138 @@ export class DataConnectApiClient { }); } - private async getUrl(version: string, locationId: string, serviceId: string, endpointId: string): Promise { - return this.getProjectId() - .then((projectId) => { - const urlParams = { - version, - projectId, - locationId, - serviceId, - endpointId - }; - let urlFormat: string; - if (useEmulator()) { - urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT, { - host: emulatorHost() - }); - } else { - urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT; - } - return utils.formatString(urlFormat, urlParams); + /** + * Executes a GraphQL query with impersonation. + * + * @param options - The GraphQL options. Must include impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public async executeQuery( + options: GraphqlOptions + ): Promise> { + return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, options); + } + + /** + * Executes a GraphQL mutation with impersonation. + * + * @param options - The GraphQL options. Must include impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public async executeMutation( + options: GraphqlOptions + ): Promise> { + return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, options); + } + + /** + * A helper function to execute operations by making requests to FDC's impersonate + * operations endpoints. + * + * @param endpoint - The endpoint to call. + * @param options - The GraphQL options, including impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + private async executeOperationHelper( + endpoint: string, + options: GraphqlOptions + ): Promise> { + if ( + typeof options.operationName === 'undefined' || + !validator.isNonEmptyString(options.operationName) + ) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`options.operationName` must be a non-empty string.' + ); + } + if ( + typeof options.impersonate === 'undefined' || + !validator.isNonNullObject(options?.impersonate) + ) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`options.impersonate` must be a non-null object.' + ); + } + + if (this.connectorConfig.connector === undefined || this.connectorConfig.connector === '') { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); + } + + const data = { + ...(options.variables && { variables: options?.variables }), + operationName: options.operationName, + extensions: { impersonate: options.impersonate }, + }; + const url = await this.getUrl( + API_VERSION, + this.connectorConfig.location, + this.connectorConfig.serviceId, + endpoint, + this.connectorConfig.connector, + ); + return this.makeGqlRequest(url, data) + .then((resp) => { + return resp; + }) + .catch((err) => { + throw this.toFirebaseError(err); }); } + /** + * Constructs the URL for a Data Connect backend request. + * + * If no connectorId is provided, will direct the request to an endpoint under services: + * .../services/{serviceId}:endpoint + * + * If connectorId is provided, will direct the request to an endpoint under connectors: + * .../services/{serviceId}/connectors/{connectorId}:endpoint + * + * @param version - The API version. + * @param locationId - The location of the Data Connect service. + * @param serviceId - The ID of the Data Connect service. + * @param endpointId - The endpoint to call. + * @param connectorId - The ID of the connector, if applicable. + * @returns A promise that fulfills with the constructed URL. + */ + private async getUrl( + version: string, + locationId: string, + serviceId: string, + endpointId: string, + connectorId?: string, + ): Promise { + const projectId = await this.getProjectId(); + const urlParams = { + version, + projectId, + locationId, + serviceId, + endpointId, + connectorId + }; + let urlFormat: string; + if (useEmulator()) { + (urlParams as any).host = emulatorHost(); + urlFormat = connectorId === undefined || connectorId === '' + ? FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT + : FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT; + } else { + urlFormat = connectorId === undefined || connectorId === '' + ? FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT + : FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT; + } + if (connectorId) { + (urlParams as any).connectorId = connectorId; + } + return utils.formatString(urlFormat, urlParams); + } + private getProjectId(): Promise { if (this.projectId) { return Promise.resolve(this.projectId); @@ -178,6 +295,32 @@ export class DataConnectApiClient { }); } + /** + * Makes a GraphQL request to the specified url. + * + * @param url - The URL to send the request to. + * @param data - The GraphQL request payload. + * @returns A promise that fulfills with the GraphQL response, or throws an error. + */ + private async makeGqlRequest(url: string, data: object): + Promise> { + const request: HttpRequestConfig = { + method: 'POST', + url, + headers: DATA_CONNECT_CONFIG_HEADERS, + data, + }; + const resp = await this.httpClient.send(request); + if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) { + const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' '); + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages); + } + return Promise.resolve({ + data: resp.data.data as GraphqlResponse, + }); + } + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; diff --git a/src/data-connect/data-connect-api.ts b/src/data-connect/data-connect-api.ts index c60ef5a2eb..ea8239458f 100644 --- a/src/data-connect/data-connect-api.ts +++ b/src/data-connect/data-connect-api.ts @@ -30,6 +30,12 @@ export interface ConnectorConfig { * Service ID of the Data Connect service. */ serviceId: string; + + /** + * Name of the Data Connect connector. + * Required for operations that interact with connectors, such as executeQuery and executeMutation. + */ + connector?: string; } /** @@ -52,7 +58,10 @@ export interface GraphqlOptions { variables?: Variables; /** - * The name of the GraphQL operation. Required only if `query` contains multiple operations. + * The name of the GraphQL operation. + * Required for operations that interact with connectors, such as executeQuery and executeMutation. + * Required for operations that interact with services, such as executeGraphql, if + * `query` contains multiple operations. */ operationName?: string; diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index a689423257..822a147a87 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -88,13 +88,13 @@ export class DataConnect { } /** - * Execute an arbitrary read-only GraphQL query - * - * @param query - The GraphQL read-only query. - * @param options - Optional {@link GraphqlOptions} when executing a read-only GraphQL query. - * - * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. - */ + * Execute an arbitrary read-only GraphQL query + * + * @param query - The GraphQL read-only query. + * @param options - Optional {@link GraphqlOptions} when executing a read-only GraphQL query. + * + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + */ public executeGraphqlRead( query: string, options?: GraphqlOptions, @@ -102,6 +102,34 @@ export class DataConnect { return this.client.executeGraphqlRead(query, options); } + /** + * Executes a pre-defined GraphQL query with impersonation. + * + * The query must be defined in your Data Connect GraphQL files. + * + * @param options - The GraphQL options, must include operationName and impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public async executeQuery( + options: GraphqlOptions + ): Promise> { + return this.client.executeQuery(options); + } + + /** + * Executes a pre-defined GraphQL mutation with impersonation. + * + * The mutation must be defined in your Data Connect GQL files. + * + * @param options - The GraphQL options, must include operationName and impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public async executeMutation( + options: GraphqlOptions + ): Promise> { + return this.client.executeMutation(options); + } + /** * Insert a single row into the specified table. * diff --git a/src/data-connect/index.ts b/src/data-connect/index.ts index 43ca313c90..5bb17afc85 100644 --- a/src/data-connect/index.ts +++ b/src/data-connect/index.ts @@ -51,6 +51,7 @@ export { * const connectorConfig: ConnectorConfig = { * location: 'us-west2', * serviceId: 'my-service', + * connectorName: 'my-connector', * }; * * // Get the `DataConnect` service for the default app From bc92c5efe5d98e77f6274787ddb99b7ba0c4f43b Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Wed, 17 Sep 2025 15:32:02 -0700 Subject: [PATCH 02/20] finish adding in changes from stephenarosaj/fdc-impersonate --- .../data-connect-api-client-internal.ts | 2 +- test/integration/data-connect.spec.ts | 931 ++++++++++++++---- .../dataconnect/dataconnect/dataconnect.yaml | 5 +- .../dataconnect/my-connector/mutations.gql | 4 +- .../dataconnect/my-connector/queries.gql | 4 +- .../data-connect-api-client-internal.spec.ts | 425 +++++++- 6 files changed, 1138 insertions(+), 233 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 56aa2e94c3..1571d5d2ec 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -28,7 +28,7 @@ import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data- const API_VERSION = 'v1'; // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -// TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE +// TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! /** The Firebase Data Connect backend service URL format. */ const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT = diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index 4cf74549cf..a1a06abe80 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -44,11 +44,11 @@ type User = { /** * // Schema * type Email @table { - * id: String! - * subject: String! - * date: Date! - * text: String! - * from: User! + * id: String! + * subject: String! + * date: Date! + * text: String! + * from: User! * } */ type Email = { @@ -59,6 +59,10 @@ type Email = { id: string; }; +interface GetUserVariables { + id: { id: string; }; +} + interface GetUserResponse { user: User; } @@ -75,16 +79,32 @@ interface UserUpdateResponse { user_update: { id: string; }; } +interface GetEmailVariables { + id: string +} + +interface GetEmailResponse { + email: Email; +} + interface EmailUpsertResponse { email_upsert: { id: string; }; } -interface ListEmailsResponse { - emails: Email[]; +interface InsertEmailVariables { + id: string; } -interface GetUserVariables { - id: { id: string; }; +interface InsertEmailResponse { + email_insert: { id: string; }; +} + +interface InsertEmailResponse { + email_insert: { id: string; }; +} + +interface ListEmailsResponse { + emails: Email[]; } interface DeleteResponse { @@ -93,8 +113,12 @@ interface DeleteResponse { } const connectorConfig: ConnectorConfig = { - location: 'us-west2', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO us-west2 - us-central1 IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + location: 'us-central1', serviceId: 'my-service', + connector: 'my-connector' }; const fredUser = { id: 'fred_id', address: '32 Elm St.', name: 'Fred' } @@ -204,223 +228,762 @@ describe('getDataConnect()', () => { user_deleteMany(all: true) }` - describe('executeGraphql()', () => { - it('executeGraphql() successfully executes a GraphQL mutation', async () => { - const fredResponse = await getDataConnect(connectorConfig).executeGraphql( - upsertFredUser - ); - //{ data: { user_insert: { id: 'fred_id' } } } - expect(fredResponse.data.user_upsert.id).to.be.not.empty; - expect(fredResponse.data.user_upsert.id).equals(fredUser.id); - - const jeffResponse = await getDataConnect(connectorConfig).executeGraphql( - upsertJeffUser - ); - //{ data: { user_insert: { id: 'jeff_id' } } } - expect(jeffResponse.data.user_upsert.id).to.be.not.empty; - expect(jeffResponse.data.user_upsert.id).equals(jeffUser.id); - - const emailResponse = await getDataConnect(connectorConfig).executeGraphql( - upsertFredEmail - ); - //{ data: { email_upsert: { id: 'email_id' } } } - expect(emailResponse.data.email_upsert.id).to.be.not.empty; - - const deleteResponse = await getDataConnect(connectorConfig).executeGraphql(deleteAll); - expect(deleteResponse.data.email_deleteMany).to.be.greaterThan(0); - expect(deleteResponse.data.user_deleteMany).to.be.greaterThan(0); - }); + const optsUnauthorizedClaims: GraphqlOptions = { + impersonate: { + unauthenticated: true + } + }; + + const optsAuthorizedFredAnonClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + firebase: { + identities: { who: 'me' }, + sign_in_provider: 'anonymous' + } + } + } + }; - it('executeGraphql() successfully executes a GraphQL query', async () => { - const resp = await getDataConnect(connectorConfig) - .executeGraphql(queryListUsers); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).to.equal(initialState.users.length); - resp.data.users.forEach((user) => { - expect(initialState.users).to.deep.include(user); + const optsAuthorizedFredClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + } + } + }; + + const optsAuthorizedFredEmailVerifiedClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + email_verified: true + } + } + }; + + const optsNonExistingClaims: GraphqlOptions = { + impersonate: { + authClaims: { + sub: 'non-exisiting-id', + email_verified: true + } + } + }; + + + describe('executeGraphql* API', () => { + describe('executeGraphql()', () => { + it('executeGraphql() successfully executes a GraphQL mutation', async () => { + const fredResponse = await getDataConnect(connectorConfig).executeGraphql( + upsertFredUser + ); + //{ data: { user_insert: { id: 'fred_id' } } } + expect(fredResponse.data.user_upsert.id).to.not.be.empty; + expect(fredResponse.data.user_upsert.id).equals(fredUser.id); + + const jeffResponse = await getDataConnect(connectorConfig).executeGraphql( + upsertJeffUser + ); + //{ data: { user_insert: { id: 'jeff_id' } } } + expect(jeffResponse.data.user_upsert.id).to.not.be.empty; + expect(jeffResponse.data.user_upsert.id).equals(jeffUser.id); + + const emailResponse = await getDataConnect(connectorConfig).executeGraphql( + upsertFredEmail + ); + //{ data: { email_upsert: { id: 'email_id' } } } + expect(emailResponse.data.email_upsert.id).to.not.be.empty; + + const deleteResponse = await getDataConnect(connectorConfig).executeGraphql(deleteAll); + expect(deleteResponse.data.email_deleteMany).to.be.greaterThan(0); + expect(deleteResponse.data.user_deleteMany).to.be.greaterThan(0); }); - }); - it('executeGraphql() use the operationName when multiple queries are provided', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - multipleQueries, - { operationName: 'ListEmails' } - ); - expect(resp.data.emails).to.not.be.empty; - expect(resp.data.emails).to.deep.equal(initialState.emails); - }); + it('executeGraphql() successfully executes a GraphQL query', async () => { + const resp = await getDataConnect(connectorConfig) + .executeGraphql(queryListUsers); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); - it('executeGraphql() should throw for a query error when no variables are provided', async () => { - return getDataConnect(connectorConfig).executeGraphql(queryGetUserById) - .should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); - }); + it('executeGraphql() use the operationName when multiple queries are provided', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + multipleQueries, + { operationName: 'ListEmails' } + ); + expect(resp.data.emails).to.not.be.empty; + expect(resp.data.emails).to.deep.equal(initialState.emails); + }); - it('executeGraphql() successfully executes a GraphQL query with variables', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryGetUserById, - { variables: { id: { id: initialState.users[0].id } } } - ); - expect(resp.data.user).to.deep.equal(initialState.users[0]); - }); - }); + it('executeGraphql() should throw for a query error when no variables are provided', async () => { + return getDataConnect(connectorConfig).executeGraphql(queryGetUserById) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); + }); - describe('executeGraphqlRead()', () => { - it('executeGraphqlRead() successfully executes a read-only GraphQL', async () => { - const resp = await getDataConnect(connectorConfig) - .executeGraphqlRead(queryListUsers); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).to.equal(initialState.users.length); - resp.data.users.forEach((user) => { - expect(initialState.users).to.deep.include(user); + it('executeGraphql() successfully executes a GraphQL query with variables', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryGetUserById, + { variables: { id: { id: initialState.users[0].id } } } + ); + expect(resp.data.user).to.deep.equal(initialState.users[0]); }); }); - it('executeGraphqlRead() should throw for a GraphQL mutation', async () => { - return getDataConnect(connectorConfig).executeGraphqlRead(upsertFredUser) - .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); - }); - }); + describe('executeGraphqlRead()', () => { + it('executeGraphqlRead() successfully executes a read-only GraphQL', async () => { + const resp = await getDataConnect(connectorConfig) + .executeGraphqlRead(queryListUsers); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); - describe('Impersonation', () => { - const optsAuthorizedFredClaims: GraphqlOptions = { - impersonate: { - authClaims: { - sub: fredUser.id, - email_verified: true - } - } - }; + it('executeGraphqlRead() should throw for a GraphQL mutation', async () => { + return getDataConnect(connectorConfig).executeGraphqlRead(upsertFredUser) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + }); - const optsNonExistingClaims: GraphqlOptions = { - impersonate: { - authClaims: { - sub: 'non-exisiting-id', - email_verified: true - } - } - }; + describe('executeGraphql* impersonation', () => { + describe('USER Auth Policy', () => { + it('executeGraphqlRead() successfully executes an impersonated query with authenticated claims', async () => { + const resp = + await getDataConnect(connectorConfig).executeGraphqlRead( + queryListUsersImpersonation, optsAuthorizedFredClaims); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(fredUser); + }); - const optsUnauthorizedClaims: GraphqlOptions = { - impersonate: { - unauthenticated: true - } - }; + it('executeGraphqlRead() should throw for impersonated query with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphqlRead( + queryListUsersImpersonation, + optsUnauthorizedClaims + ) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); - describe('USER Auth Policy', () => { - it('executeGraphqlRead() successfully executes an impersonated query with authenticated claims', async () => { - const resp = - await getDataConnect(connectorConfig).executeGraphqlRead( + it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsersImpersonation, optsAuthorizedFredClaims); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).equals(1); - expect(resp.data.users[0]).to.deep.equal(fredUser); - }); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(fredUser); + }); - it('executeGraphqlRead() should throw for impersonated query with unauthenticated claims', async () => { - return getDataConnect(connectorConfig).executeGraphqlRead( - queryListUsersImpersonation, - optsUnauthorizedClaims - ) - .should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); - }); + it('executeGraphql() should throw for impersonated query with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphql(queryListUsersImpersonation, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('executeGraphql() should return an empty list for an impersonated query with non-existing authenticated ' + + 'claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsersImpersonation, optsNonExistingClaims); + // Should find no data + expect(resp.data.users).to.be.empty; + }); - it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryListUsersImpersonation, optsAuthorizedFredClaims); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).equals(1); - expect(resp.data.users[0]).to.deep.equal(fredUser); + it('executeGraphql() successfully executes an impersonated mutation with authenticated claims', + async () => { + const updateResp = await getDataConnect(connectorConfig).executeGraphql( + updateFredrickUserImpersonated, optsAuthorizedFredClaims); + // Fred -> Fredrick + expect(updateResp.data.user_update.id).equals(fredUser.id); + const queryResp = await getDataConnect(connectorConfig).executeGraphql( + queryGetUserById, { variables: { id: { id: fredUser.id } } }); + expect(queryResp.data.user).to.not.be.empty; + expect(queryResp.data.user).to.deep.equal(fredrickUser); + }); + + it('executeGraphql() should throw for impersonated mutation with unauthenticated claims', async () => { + return getDataConnect(connectorConfig).executeGraphql(updateFredrickUserImpersonated, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('executeGraphql() should return null for an impersonated mutation with non-existing authenticated claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + updateFredrickUserImpersonated, optsNonExistingClaims); + // Should mutate no data + expect(resp.data.user_update).to.be.null; + }); }); - it('executeGraphql() should throw for impersonated query with unauthenticated claims', async () => { - return getDataConnect(connectorConfig).executeGraphql(queryListUsersImpersonation, optsUnauthorizedClaims) - .should.eventually.be.rejected.and.has.property('code', 'data-connect/unauthenticated'); + describe('PUBLIC Auth Policy', () => { + it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsAuthorizedFredClaims); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('executeGraphql() successfully executes an impersonated query with unauthenticated claims', async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsUnauthorizedClaims); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('executeGraphql() successfully executes an impersonated query with non-existing authenticated claims', + async () => { + const resp = await getDataConnect(connectorConfig).executeGraphql( + queryListUsers, optsNonExistingClaims); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); }); - it('executeGraphql() should return an empty list for an impersonated query with non-existing authenticated ' + - 'claims', - async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryListUsersImpersonation, optsNonExistingClaims); - // Should find no data - expect(resp.data.users).to.be.empty; + describe('NO_ACCESS Auth Policy', () => { + it('executeGraphql() should throw for an impersonated query with authenticated claims', async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsAuthorizedFredClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it('executeGraphql() should throw for an impersonated query with unauthenticated claims', async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsUnauthorizedClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it('executeGraphql() should throw for an impersonated query with non-existing authenticated claims', + async () => { + return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsNonExistingClaims) + .should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); }); + }); + }); - it('executeGraphql() successfully executes an impersonated mutation with authenticated claims', - async () => { - const updateResp = await getDataConnect(connectorConfig).executeGraphql( - updateFredrickUserImpersonated, optsAuthorizedFredClaims); - // Fred -> Fredrick - expect(updateResp.data.user_update.id).equals(fredUser.id); - const queryResp = await getDataConnect(connectorConfig).executeGraphql( - queryGetUserById, { variables: { id: { id: fredUser.id } } }); - expect(queryResp.data.user).to.not.be.empty; - expect(queryResp.data.user).to.deep.equal(fredrickUser); - }); - - it('executeGraphql() should throw for impersonated mutation with unauthenticated claims', async () => { - return getDataConnect(connectorConfig).executeGraphql(updateFredrickUserImpersonated, optsUnauthorizedClaims) - .should.eventually.be.rejected.and.has.property('code', 'data-connect/unauthenticated'); + describe('execute operation API', () => { + describe('executeQuery()', () => { + it("should fail when executing a query which doesn't exist", async () => { + return getDataConnect(connectorConfig).executeQuery( + { + ...optsUnauthorizedClaims, + operationName: 'DOES_NOT_EXIST!!!' + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); + }) + + describe('with unauthenticated impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsUnauthorizedClaims, operationName: 'ListUsersPublic' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should fail to execute a query with @auth(level: USER_ANON)', () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsUnauthorizedClaims, operationName: 'ListUsersUserAnon' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: USER)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsUnauthorizedClaims, operationName: 'ListUsersUser' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsUnauthorizedClaims, operationName: 'ListUsersUserEmailVerified' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsUnauthorizedClaims, operationName: 'ListUsersNoAccess' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); }); - it('executeGraphql() should return null for an impersonated mutation with non-existing authenticated claims', - async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - updateFredrickUserImpersonated, optsNonExistingClaims); - // Should mutate no data - expect(resp.data.user_update).to.be.null; + describe('with authenticated anonymous impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersPublic' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); }); - }); - describe('PUBLIC Auth Policy', () => { - it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryListUsers, optsAuthorizedFredClaims); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).to.equal(initialState.users.length); - resp.data.users.forEach((user) => { - expect(initialState.users).to.deep.include(user); + it('should fail to execute a query with @auth(level: USER)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUser' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserEmailVerified' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersNoAccess' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersImpersonationAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(fredUser); }); }); - it('executeGraphql() successfully executes an impersonated query with unauthenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryListUsers, optsUnauthorizedClaims); - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users.length).to.equal(initialState.users.length); - resp.data.users.forEach((user) => { - expect(initialState.users).to.deep.include(user); + describe('with authenticated user impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersPublic' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersUser' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserEmailVerified' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersNoAccess' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredClaims, operationName: 'ListUsersImpersonationAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(fredUser); }); }); - it('executeGraphql() successfully executes an impersonated query with non-existing authenticated claims', - async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( - queryListUsers, optsNonExistingClaims); - expect(resp.data.users).to.be.not.empty; + describe('with authenticated email verified user impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersPublic' } + ); + expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { expect(initialState.users).to.deep.include(user); }); }); + + it('should successfully execute a query with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUser' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserEmailVerified' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersNoAccess' } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersImpersonationAnon' } + ); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).equals(1); + expect(resp.data.users[0]).to.deep.equal(fredUser); + }); + }); }); - describe('NO_ACCESS Auth Policy', () => { - it('executeGraphql() should throw for an impersonated query with authenticated claims', async () => { - return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsAuthorizedFredClaims) - .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + describe('executeMutation()', () => { + it("should fail when executing a mutation which doesn't exist", async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'DOES_NOT_EXIST!!!' + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); + }) + + describe('with unauthenticated impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'InsertEmailPublic', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should fail to execute a mutation with @auth(level: USER_ANON)', () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'InsertEmailUserAnon', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: USER)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'InsertEmailUser', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'InsertEmailUserEmailVerified', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsUnauthorizedClaims, + operationName: 'InsertEmailNoAccess', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + }); + + describe('with authenticated anonymous impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailPublic', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailUserAnon', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should fail to execute a mutation with @auth(level: USER)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailUser', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailUserEmailVerified', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailNoAccess', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const insertResp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'InsertEmailImpersonation', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + { + ...optsAuthorizedFredAnonClaims, + operationName: 'GetEmail', + variables: { id: insertResp.data.email_insert.id } + } + ); + expect(queryResp.data.email.from.id).to.equal(fredUser.id); + }); }); - it('executeGraphql() should throw for an impersonated query with unauthenticated claims', async () => { - return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsUnauthorizedClaims) - .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + describe('with authenticated user impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailPublic', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should fail to execute a mutation with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailUserAnon', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailUser', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailUserEmailVerified', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + }); + + it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailNoAccess', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const insertResp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredClaims, + operationName: 'InsertEmailImpersonation', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + { + ...optsAuthorizedFredClaims, + operationName: 'GetEmail', + variables: { id: insertResp.data.email_insert.id } + } + ); + expect(queryResp.data.email.from.id).to.equal(fredUser.id); + }); }); - it('executeGraphql() should throw for an impersonated query with non-existing authenticated claims', - async () => { - return await getDataConnect(connectorConfig).executeGraphql(queryListEmails, optsNonExistingClaims) - .should.eventually.be.rejected.and.has.property('code', 'data-connect/permission-denied'); + describe('with authenticated email verified user impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailPublic', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailUserAnon', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ) + expect(resp.data.email_insert.id).to.not.be.undefined; }); + + it('should successfully execute a mutation with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailUser', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ) + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailUserEmailVerified', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ) + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { + return getDataConnect(connectorConfig).executeMutation( + { ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailNoAccess', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + + it("should use the impersonated user's auth.uid", async () => { + const insertResp = await getDataConnect(connectorConfig) + .executeMutation( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'InsertEmailImpersonation', + variables: { id: `email_id_${Math.random() * 1000}` } + } + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + { + ...optsAuthorizedFredEmailVerifiedClaims, + operationName: 'GetEmail', + variables: { id: insertResp.data.email_insert.id } + } + ); + expect(queryResp.data.email.from.id).to.equal(fredUser.id); + }); + }); }); }); -}); +}); \ No newline at end of file diff --git a/test/integration/dataconnect/dataconnect/dataconnect.yaml b/test/integration/dataconnect/dataconnect/dataconnect.yaml index 2f5b815b88..ebd2b5a957 100644 --- a/test/integration/dataconnect/dataconnect/dataconnect.yaml +++ b/test/integration/dataconnect/dataconnect/dataconnect.yaml @@ -1,6 +1,9 @@ specVersion: "v1" serviceId: "my-service" -location: "us-west2" +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +# TODO: CHANGE THIS BACK TO us-west2 - us-central1 IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +location: "us-central1" schema: source: "./schema" datasource: diff --git a/test/integration/dataconnect/dataconnect/my-connector/mutations.gql b/test/integration/dataconnect/dataconnect/my-connector/mutations.gql index 10a870880e..aa31d68691 100644 --- a/test/integration/dataconnect/dataconnect/my-connector/mutations.gql +++ b/test/integration/dataconnect/dataconnect/my-connector/mutations.gql @@ -1,7 +1,7 @@ mutation upsertFredUser @auth(level: NO_ACCESS) { user_upsert(data: { id: "fred_id", address: "32 Elm St.", name: "Fred" }) } -mutation updateFredrickUserImpersonation @auth(level: USER) { +mutation updateFredrickUserImpersonation @auth(level: USER, insecureReason: "test") { user_update( key: { id_expr: "auth.uid" } data: { address: "64 Elm St. North", name: "Fredrick" } @@ -82,7 +82,7 @@ mutation InsertEmailNoAccess($id: String!) @auth(level: NO_ACCESS) { } ) } -mutation InsertEmailImpersonation($id: String!) @auth(level: NO_ACCESS) { +mutation InsertEmailImpersonation($id: String!) @auth(level: USER_ANON, insecureReason: "test") { email_insert( data: { id: $id diff --git a/test/integration/dataconnect/dataconnect/my-connector/queries.gql b/test/integration/dataconnect/dataconnect/my-connector/queries.gql index b93da41828..5ac7d6c48e 100644 --- a/test/integration/dataconnect/dataconnect/my-connector/queries.gql +++ b/test/integration/dataconnect/dataconnect/my-connector/queries.gql @@ -34,7 +34,7 @@ query ListUsersNoAccess @auth(level: NO_ACCESS) { address } } -query ListUsersImpersonationAnon @auth(level: USER_ANON) { +query ListUsersImpersonationAnon @auth(level: USER_ANON, insecureReason: "test") { users(where: { id: { eq_expr: "auth.uid" } }) { id name @@ -59,7 +59,7 @@ query ListEmails @auth(level: NO_ACCESS) { } } } -query GetEmail($id: String!) @auth(level: NO_ACCESS) { +query GetEmail($id: String!) @auth(level: USER_ANON, insecureReason: "test") { email(id: $id) { id subject diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index 8086802861..287f52a4be 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -1,6 +1,6 @@ /*! * @license - * Copyright 2024 Google LLC + * Copyright 2024 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ import * as mocks from '../../resources/mocks'; import { DATA_CONNECT_ERROR_CODE_MAPPING, DataConnectApiClient, FirebaseDataConnectError } from '../../../src/data-connect/data-connect-api-client-internal'; import { FirebaseApp } from '../../../src/app/firebase-app'; -import { ConnectorConfig } from '../../../src/data-connect'; +import { ConnectorConfig, GraphqlOptions } from '../../../src/data-connect'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils'; describe('DataConnectApiClient', () => { @@ -69,6 +69,7 @@ describe('DataConnectApiClient', () => { const connectorConfig: ConnectorConfig = { location: 'us-west2', serviceId: 'my-service', + connector: 'my-connector', }; const clientWithoutProjectId = new DataConnectApiClient( @@ -207,7 +208,10 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, headers: EXPECTED_HEADERS, data: { query: 'query' } }); @@ -230,6 +234,340 @@ describe('DataConnectApiClient', () => { }); }); }); + + describe('executeQuery', () => { + const unauthenticatedOptions: GraphqlOptions = + { operationName: 'unauthenticatedQuery', impersonate: { unauthenticated: true } }; + const authenticatedOptions: GraphqlOptions = + { operationName: 'authenticatedQuery', impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; + + it('should reject when no operationName is provided', () => { + apiClient.executeQuery({ impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); + apiClient.executeQuery({ operationName: undefined, impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); + }); + it('should reject when no impersonate object is provided', () => { + apiClient.executeQuery({ operationName: 'queryName' }) + .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + apiClient.executeQuery({ operationName: 'queryName', impersonate: undefined }) + .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + }); + it('should reject when project id is not available', () => { + clientWithoutProjectId.executeQuery(unauthenticatedOptions) + .should.eventually.be.rejectedWith(noProjectId); + }); + it('should reject when no connectorId is provided', () => { + apiClient = new DataConnectApiClient( + { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, + app + ); + apiClient.executeQuery({ impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith( + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); + }); + + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClient.executeQuery(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClient.executeQuery(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.executeQuery(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClient.executeQuery(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + describe('should resolve with the GraphQL response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + it('for an unauthenticated request', () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeQuery(unauthenticatedOptions).then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EXPECTED_HEADERS, + data: { + operationName: unauthenticatedOptions.operationName, + extensions: { impersonate: unauthenticatedOptions.impersonate } + } + }); + }); + }); + it('for an authenticated request', () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeQuery(authenticatedOptions).then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EXPECTED_HEADERS, + data: { + operationName: authenticatedOptions.operationName, + extensions: { impersonate: authenticatedOptions.impersonate } + } + }); + }); + }); + }); + + it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeQuery(unauthenticatedOptions) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: unauthenticatedOptions.operationName, + extensions: { impersonate: unauthenticatedOptions.impersonate } + } + }); + }); + }); + }); + + const unauthenticatedOptions: GraphqlOptions = + { operationName: 'operationName', impersonate: { unauthenticated: true } }; + const authenticatedOptions: GraphqlOptions = + { operationName: 'operationName', impersonate: { unauthenticated: true } }; + + describe('executeMutation', () => { + it('should reject when no operationName is provided', () => { + apiClient.executeMutation({ impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); + apiClient.executeMutation({ operationName: undefined, impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); + }); + it('should reject when no impersonate object is provided', () => { + apiClient.executeMutation({ operationName: 'queryName' }) + .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + apiClient.executeMutation({ operationName: 'queryName', impersonate: undefined }) + .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + }); + it('should reject when project id is not available', () => { + clientWithoutProjectId.executeMutation(unauthenticatedOptions) + .should.eventually.be.rejectedWith(noProjectId); + }); + it('should reject when no connectorId is provided', () => { + apiClient = new DataConnectApiClient( + { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, + app + ); + apiClient.executeMutation({ impersonate: { unauthenticated: true } }) + .should.eventually.be.rejectedWith( + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); + }); + + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClient.executeMutation(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClient.executeMutation(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.executeMutation(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClient.executeMutation(unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + describe('should resolve with the GraphQL response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + it('for an unauthenticated request', () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeMutation(unauthenticatedOptions) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // eslint-disable-next-line max-len + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EXPECTED_HEADERS, + data: { + operationName: unauthenticatedOptions.operationName, + extensions: { impersonate: unauthenticatedOptions.impersonate } + } + }); + }); + }); + it('for an authenticated request', () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeMutation(authenticatedOptions) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // eslint-disable-next-line max-len + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EXPECTED_HEADERS, + data: { + operationName: authenticatedOptions.operationName, + extensions: { impersonate: authenticatedOptions.impersonate } + } + }); + }); + }); + }); + + it('should resolve with the GraphQL response on success for an authenticated request', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeMutation(unauthenticatedOptions) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EXPECTED_HEADERS, + data: { + operationName: unauthenticatedOptions.operationName, + extensions: { impersonate: unauthenticatedOptions.impersonate } + } + }); + }); + }); + + it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClient.executeMutation(unauthenticatedOptions) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: unauthenticatedOptions.operationName, + extensions: { impersonate: unauthenticatedOptions.impersonate } + } + }); + }); + }); + }); }); describe('DataConnectApiClient CRUD helpers', () => { @@ -241,6 +579,7 @@ describe('DataConnectApiClient CRUD helpers', () => { const connectorConfig: ConnectorConfig = { location: 'us-west1', serviceId: 'my-crud-service', + connector: 'my-crud-connector', }; const mockOptions = { @@ -314,9 +653,9 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_insert(data: { - name: "test", - value: 123 - }) + name: "test", + value: 123 + }) }`; await apiClient.insert(tableName, simpleData); expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); @@ -327,9 +666,9 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_insert(data: { - id: "abc", active: true, scores: [10, 20], - info: { nested: "yes/no \\"quote\\" \\\\slash\\\\" } - }) + id: "abc", active: true, scores: [10, 20], + info: { nested: "yes/no \\"quote\\" \\\\slash\\\\" } + }) }`; await apiClient.insert(tableName, complexData); expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); @@ -339,12 +678,12 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_insert(data: { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }) + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] + }) }`; await apiClient.insert(tableName, dataWithUndefined); expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); @@ -400,7 +739,7 @@ describe('DataConnectApiClient CRUD helpers', () => { mutation { ${formatedTableName}_insertMany(data: [{ id: "a", active: true, info: { nested: "n1 \\"quote\\"" } }, { id: "b", scores: [1, 2], - info: { nested: "n2/\\\\" } }]) }`; + info: { nested: "n2/\\\\" } }]) }`; await apiClient.insertMany(tableName, complexDataArray); expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); }); @@ -413,18 +752,18 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_insertMany(data: [{ - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] }, { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] }]) }`; await apiClient.insertMany(tableName, dataArray); @@ -488,12 +827,12 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_upsert(data: { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] - }) + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] + }) }`; await apiClient.upsert(tableName, dataWithUndefined); expect(executeGraphqlStub).to.have.been.calledOnceWithExactly(normalizeGraphQLString(expectedMutation)); @@ -559,18 +898,18 @@ describe('DataConnectApiClient CRUD helpers', () => { const expectedMutation = ` mutation { ${formatedTableName}_upsertMany(data: [{ - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] }, { - genre: "Action", - title: "Die Hard", - ratings: null, - director: {}, - extras: [1, null, "hello", null, { a: 1 }] + genre: "Action", + title: "Die Hard", + ratings: null, + director: {}, + extras: [1, null, "hello", null, { a: 1 }] }]) }`; await apiClient.upsertMany(tableName, dataArray); @@ -602,4 +941,4 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); }); }); -}); +}); \ No newline at end of file From d43588ec910760a9a9c21b89ac17d00ee23d8f8f Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Wed, 17 Sep 2025 15:41:38 -0700 Subject: [PATCH 03/20] update Google Inc. to Google LLC, run npm install; npm run build --- package-lock.json | 4 ++-- .../data-connect/data-connect-api-client-internal.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d6cea5157..01d3530e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebase-admin", - "version": "13.4.0", + "version": "13.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebase-admin", - "version": "13.4.0", + "version": "13.5.0", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index 287f52a4be..41966e6253 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -1,6 +1,6 @@ /*! * @license - * Copyright 2024 Google Inc. + * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From dcd493f96d00e89c067325b764e7c18f5d5737ae Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Wed, 17 Sep 2025 15:44:04 -0700 Subject: [PATCH 04/20] run npm apidocs --- etc/firebase-admin.data-connect.api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index 8a1e7a042c..1f688fe44c 100644 --- a/etc/firebase-admin.data-connect.api.md +++ b/etc/firebase-admin.data-connect.api.md @@ -13,6 +13,7 @@ export type AuthClaims = Partial; // @public export interface ConnectorConfig { + connector?: string; location: string; serviceId: string; } @@ -27,6 +28,8 @@ export class DataConnect { readonly connectorConfig: ConnectorConfig; executeGraphql(query: string, options?: GraphqlOptions): Promise>; executeGraphqlRead(query: string, options?: GraphqlOptions): Promise>; + executeMutation(options: GraphqlOptions): Promise>; + executeQuery(options: GraphqlOptions): Promise>; insert(tableName: string, variables: Variables): Promise>; insertMany>(tableName: string, variables: Variables): Promise>; upsert(tableName: string, variables: Variables): Promise>; From 23fe1f4aee11285d41eb3763030002c665601e8c Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Wed, 24 Sep 2025 15:53:20 -0700 Subject: [PATCH 05/20] remove public execute apis --- src/data-connect/data-connect.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index 822a147a87..ed4a5bf769 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -103,33 +103,6 @@ export class DataConnect { } /** - * Executes a pre-defined GraphQL query with impersonation. - * - * The query must be defined in your Data Connect GraphQL files. - * - * @param options - The GraphQL options, must include operationName and impersonation details. - * @returns A promise that fulfills with the GraphQL response. - */ - public async executeQuery( - options: GraphqlOptions - ): Promise> { - return this.client.executeQuery(options); - } - - /** - * Executes a pre-defined GraphQL mutation with impersonation. - * - * The mutation must be defined in your Data Connect GQL files. - * - * @param options - The GraphQL options, must include operationName and impersonation details. - * @returns A promise that fulfills with the GraphQL response. - */ - public async executeMutation( - options: GraphqlOptions - ): Promise> { - return this.client.executeMutation(options); - } - /** * Insert a single row into the specified table. * From ae8096a2011f064dd5796e425fd81c319ab1bbc3 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Thu, 25 Sep 2025 14:28:26 -0700 Subject: [PATCH 06/20] convert executeOperation api to OperationRef(...).execute() api --- src/data-connect/data-connect.ts | 139 +++++++++- test/integration/data-connect.spec.ts | 242 +++++++++--------- .../dataconnect/dataconnect/dataconnect.yaml | 5 +- .../dataconnect/my-connector/queries.gql | 3 +- 4 files changed, 270 insertions(+), 119 deletions(-) diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index ed4a5bf769..62405f188b 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -16,7 +16,10 @@ */ import { App } from '../app'; -import { DataConnectApiClient } from './data-connect-api-client-internal'; +import { + DATA_CONNECT_ERROR_CODE_MAPPING, + DataConnectApiClient, + FirebaseDataConnectError } from './data-connect-api-client-internal'; import { ConnectorConfig, @@ -102,7 +105,6 @@ export class DataConnect { return this.client.executeGraphqlRead(query, options); } - /** /** * Insert a single row into the specified table. * @@ -158,4 +160,137 @@ export class DataConnect { ): Promise> { return this.client.upsertMany(tableName, variables); } + + /** + * Create a reference to a specific "instance" of a named query. + * @param name Name of query + * @param impersonate Impersonation options for this query + * @returns an reference to the named query with the specified impersonation and variables. + */ + public queryRef( + options: GraphqlOptions + ): QueryRef; + + /** + * Create a reference to a specific "instance" of a named query. + * @param name Name of query + * @param impersonate Impersonation options for this query + * @param variables Variables passed to this query, may be omitted. + * @returns an reference to the named query with the specified impersonation and variables. + */ + public queryRef( + options: GraphqlOptions + ): QueryRef; + + public queryRef( + options: GraphqlOptions + ): QueryRef { + if (!('connector' in this.connectorConfig)){ + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when creating a queryRef.`); + } + return new QueryRef(this, options, this.client); + } + + /** + * Create a reference to a specific "instance" of a named mutation. + * @param name Name of mutation + * @param impersonate Impersonation options for this mutation + * @returns an reference to the named mutation with the specified impersonation and variables. + */ + public mutationRef( + options: GraphqlOptions + ): MutationRef + + /** + * Create a reference to a specific "instance" of a named mutation. + * @param name Name of mutation + * @param impersonate Impersonation options for this mutation + * @param variables Variables passed to this mutation, may be omitted. + * @returns an reference to the named mutation with the specified impersonation and variables. + */ + public mutationRef( + options: GraphqlOptions + ): MutationRef; + /** + * Create a reference to a specific "instance" of a named mutation. + * @param name Name of mutation + * @param impersonate Impersonation options for this mutation + * @param variables Variables passed to this mutation, may be omitted. + * @returns an reference to the named mutation with the specified impersonation and variables. + */ + public mutationRef( + options: GraphqlOptions + ): MutationRef { + if (!('connector' in this.connectorConfig)){ + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when creating a mutationRef.`); + } + return new MutationRef(this, options, this.client); + } } + +interface OperationResult { + ref: OperationRef; + data: Data; + variables: Variables; + dataConnect: DataConnect; +} + +export interface QueryResult extends OperationResult { + ref: QueryRef; +} + +export interface MutationResult extends OperationResult { + ref: MutationRef; +} + +abstract class OperationRef { + constructor( + public readonly dataConnect: DataConnect, + public readonly options: GraphqlOptions, + protected readonly client: DataConnectApiClient + ) { + if (typeof options.operationName === 'undefined') { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + `The 'options.operationName' field must be provided when creating a queryRef + or mutationRef.`); + } + if (typeof options.impersonate === 'undefined') { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + `The 'options.impersonate' field must be provided when creating a queryRef + or mutationRef.`); + } + } + abstract execute(): Promise>; +} + +class QueryRef extends OperationRef { + async execute(): Promise> { + const { data } = await this.client.executeQuery(this.options); + return { + ref: this, + data: data, + variables: this.options.variables as Variables, + dataConnect: this.dataConnect + } + } +} + +class MutationRef extends OperationRef { + async execute(): Promise> { + const { data } = await this.client.executeMutation(this.options) + return { + ref: this, + data: data, + variables: this.options.variables as Variables, + dataConnect: this.dataConnect + } + } +} \ No newline at end of file diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index a1a06abe80..e788cb14d0 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -303,7 +303,7 @@ describe('getDataConnect()', () => { it('executeGraphql() successfully executes a GraphQL query', async () => { const resp = await getDataConnect(connectorConfig) - .executeGraphql(queryListUsers); + .executeGraphql(queryListUsers); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -337,7 +337,7 @@ describe('getDataConnect()', () => { describe('executeGraphqlRead()', () => { it('executeGraphqlRead() successfully executes a read-only GraphQL', async () => { const resp = await getDataConnect(connectorConfig) - .executeGraphqlRead(queryListUsers); + .executeGraphqlRead(queryListUsers); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -355,7 +355,7 @@ describe('getDataConnect()', () => { describe('USER Auth Policy', () => { it('executeGraphqlRead() successfully executes an impersonated query with authenticated claims', async () => { const resp = - await getDataConnect(connectorConfig).executeGraphqlRead( + await getDataConnect(connectorConfig).executeGraphqlRead( queryListUsersImpersonation, optsAuthorizedFredClaims); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); @@ -371,7 +371,7 @@ describe('getDataConnect()', () => { }); it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsersImpersonation, optsAuthorizedFredClaims); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); @@ -386,7 +386,7 @@ describe('getDataConnect()', () => { it('executeGraphql() should return an empty list for an impersonated query with non-existing authenticated ' + 'claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsersImpersonation, optsNonExistingClaims); // Should find no data expect(resp.data.users).to.be.empty; @@ -394,7 +394,7 @@ describe('getDataConnect()', () => { it('executeGraphql() successfully executes an impersonated mutation with authenticated claims', async () => { - const updateResp = await getDataConnect(connectorConfig).executeGraphql( + const updateResp = await getDataConnect(connectorConfig).executeGraphql( updateFredrickUserImpersonated, optsAuthorizedFredClaims); // Fred -> Fredrick expect(updateResp.data.user_update.id).equals(fredUser.id); @@ -411,7 +411,7 @@ describe('getDataConnect()', () => { it('executeGraphql() should return null for an impersonated mutation with non-existing authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( updateFredrickUserImpersonated, optsNonExistingClaims); // Should mutate no data expect(resp.data.user_update).to.be.null; @@ -420,7 +420,7 @@ describe('getDataConnect()', () => { describe('PUBLIC Auth Policy', () => { it('executeGraphql() successfully executes an impersonated query with authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsers, optsAuthorizedFredClaims); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -430,7 +430,7 @@ describe('getDataConnect()', () => { }); it('executeGraphql() successfully executes an impersonated query with unauthenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsers, optsUnauthorizedClaims); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -441,7 +441,7 @@ describe('getDataConnect()', () => { it('executeGraphql() successfully executes an impersonated query with non-existing authenticated claims', async () => { - const resp = await getDataConnect(connectorConfig).executeGraphql( + const resp = await getDataConnect(connectorConfig).executeGraphql( queryListUsers, optsNonExistingClaims); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -471,22 +471,34 @@ describe('getDataConnect()', () => { }); }); - describe('execute operation API', () => { - describe('executeQuery()', () => { + describe('operation ref API', () => { + describe('queryRef()', () => { it("should fail when executing a query which doesn't exist", async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'DOES_NOT_EXIST!!!' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); + }) + + it('should execut a query with variables', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + { + ...optsUnauthorizedClaims, + operationName: 'GetUser', + variables: { id: { id: fredUser.id } } + } + ).execute(); + expect(resp.data.user).to.not.be.empty; + expect(resp.data.user).to.deep.equal(fredUser); }) describe('with unauthenticated impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'ListUsersPublic' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -495,35 +507,35 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER_ANON)', () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'ListUsersUserAnon' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'ListUsersUser' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'ListUsersUserEmailVerified' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsUnauthorizedClaims, operationName: 'ListUsersNoAccess' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); }); describe('with authenticated anonymous impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersPublic' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -532,9 +544,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -543,27 +555,27 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUser' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserEmailVerified' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersNoAccess' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersImpersonationAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); expect(resp.data.users[0]).to.deep.equal(fredUser); @@ -572,9 +584,9 @@ describe('getDataConnect()', () => { describe('with authenticated user impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersPublic' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -583,9 +595,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -594,9 +606,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersUser' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -605,21 +617,21 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserEmailVerified' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersNoAccess' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'ListUsersImpersonationAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); expect(resp.data.users[0]).to.deep.equal(fredUser); @@ -628,9 +640,9 @@ describe('getDataConnect()', () => { describe('with authenticated email verified user impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersPublic' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -639,9 +651,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -650,9 +662,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUser' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -661,9 +673,9 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserEmailVerified' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); resp.data.users.forEach((user) => { @@ -672,15 +684,15 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeQuery( + return getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersNoAccess' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).executeQuery( + const resp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersImpersonationAnon' } - ); + ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); expect(resp.data.users[0]).to.deep.equal(fredUser); @@ -688,142 +700,142 @@ describe('getDataConnect()', () => { }); }); - describe('executeMutation()', () => { + describe('mutationRef()', () => { it("should fail when executing a mutation which doesn't exist", async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsUnauthorizedClaims, operationName: 'DOES_NOT_EXIST!!!' } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); }) describe('with unauthenticated impersonation', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsUnauthorizedClaims, operationName: 'InsertEmailPublic', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER_ANON)', () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsUnauthorizedClaims, operationName: 'InsertEmailUserAnon', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsUnauthorizedClaims, operationName: 'InsertEmailUser', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsUnauthorizedClaims, operationName: 'InsertEmailUserEmailVerified', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsUnauthorizedClaims, operationName: 'InsertEmailNoAccess', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); }); describe('with authenticated anonymous impersonation', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailPublic', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailUserAnon', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailUser', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailUserEmailVerified', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailNoAccess', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredAnonClaims, operationName: 'InsertEmailImpersonation', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; - const queryResp = await getDataConnect(connectorConfig).executeQuery( + const queryResp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredAnonClaims, operationName: 'GetEmail', variables: { id: insertResp.data.email_insert.id } } - ); + ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); }); @@ -831,77 +843,77 @@ describe('getDataConnect()', () => { describe('with authenticated user impersonation', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailPublic', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailUserAnon', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should successfully execute a mutation with @auth(level: USER)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailUser', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailUserEmailVerified', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailNoAccess', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredClaims, operationName: 'InsertEmailImpersonation', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; - const queryResp = await getDataConnect(connectorConfig).executeQuery( + const queryResp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredClaims, operationName: 'GetEmail', variables: { id: insertResp.data.email_insert.id } } - ); + ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); }); @@ -909,78 +921,78 @@ describe('getDataConnect()', () => { describe('with authenticated email verified user impersonation', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailPublic', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailUserAnon', variables: { id: `email_id_${Math.random() * 1000}` } } - ) + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should successfully execute a mutation with @auth(level: USER)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailUser', variables: { id: `email_id_${Math.random() * 1000}` } } - ) + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should successfully execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', async () => { const resp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailUserEmailVerified', variables: { id: `email_id_${Math.random() * 1000}` } } - ) + ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).executeMutation( + return getDataConnect(connectorConfig).mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailNoAccess', variables: { id: `email_id_${Math.random() * 1000}` } } - ).should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) - .executeMutation( + .mutationRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'InsertEmailImpersonation', variables: { id: `email_id_${Math.random() * 1000}` } } - ); + ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; - const queryResp = await getDataConnect(connectorConfig).executeQuery( + const queryResp = await getDataConnect(connectorConfig).queryRef( { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'GetEmail', variables: { id: insertResp.data.email_insert.id } } - ); + ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); }); diff --git a/test/integration/dataconnect/dataconnect/dataconnect.yaml b/test/integration/dataconnect/dataconnect/dataconnect.yaml index ebd2b5a957..75bad94962 100644 --- a/test/integration/dataconnect/dataconnect/dataconnect.yaml +++ b/test/integration/dataconnect/dataconnect/dataconnect.yaml @@ -10,7 +10,10 @@ schema: postgresql: database: "my-database" cloudSql: - instanceId: "my-instance" + # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + # TODO: CHANGE THIS BACK TO my-instance - my-instance-autopush IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + instanceId: "my-instance-autopush" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./my-connector"] diff --git a/test/integration/dataconnect/dataconnect/my-connector/queries.gql b/test/integration/dataconnect/dataconnect/my-connector/queries.gql index 5ac7d6c48e..9c1a403186 100644 --- a/test/integration/dataconnect/dataconnect/my-connector/queries.gql +++ b/test/integration/dataconnect/dataconnect/my-connector/queries.gql @@ -41,10 +41,11 @@ query ListUsersImpersonationAnon @auth(level: USER_ANON, insecureReason: "test") address } } -query GetUser($id: User_Key!) @auth(level: NO_ACCESS) { +query GetUser($id: User_Key!) @auth(level: PUBLIC, insecureReason: "test") { user(key: $id) { id name + address } } From 52a18d7091c94ba9683566729497122b1611285c Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Thu, 25 Sep 2025 15:24:14 -0700 Subject: [PATCH 07/20] remove internal client from operation refs --- src/data-connect/data-connect.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index 62405f188b..cb79d82e58 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -161,6 +161,16 @@ export class DataConnect { return this.client.upsertMany(tableName, variables); } + /** @internal */ + public executeQuery(options: GraphqlOptions): Promise> { + return this.client.executeQuery(options); + } + + /** @internal */ + public executeMutation(options: GraphqlOptions): Promise> { + return this.client.executeMutation(options); + } + /** * Create a reference to a specific "instance" of a named query. * @param name Name of query @@ -191,7 +201,7 @@ export class DataConnect { `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when creating a queryRef.`); } - return new QueryRef(this, options, this.client); + return new QueryRef(this, options); } /** @@ -230,7 +240,7 @@ export class DataConnect { `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when creating a mutationRef.`); } - return new MutationRef(this, options, this.client); + return new MutationRef(this, options); } } @@ -253,7 +263,6 @@ abstract class OperationRef { constructor( public readonly dataConnect: DataConnect, public readonly options: GraphqlOptions, - protected readonly client: DataConnectApiClient ) { if (typeof options.operationName === 'undefined') { throw new FirebaseDataConnectError( @@ -273,7 +282,7 @@ abstract class OperationRef { class QueryRef extends OperationRef { async execute(): Promise> { - const { data } = await this.client.executeQuery(this.options); + const { data } = await this.dataConnect.executeQuery(this.options); return { ref: this, data: data, @@ -285,7 +294,7 @@ class QueryRef extends OperationRef { class MutationRef extends OperationRef { async execute(): Promise> { - const { data } = await this.client.executeMutation(this.options) + const { data } = await this.dataConnect.executeMutation(this.options) return { ref: this, data: data, From 3cb664593de94ed1da133947ab26def7063a657a Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Thu, 25 Sep 2025 15:32:11 -0700 Subject: [PATCH 08/20] cleanup javadocs to address workflow failures --- src/data-connect/data-connect.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index cb79d82e58..d147e22f28 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -173,8 +173,7 @@ export class DataConnect { /** * Create a reference to a specific "instance" of a named query. - * @param name Name of query - * @param impersonate Impersonation options for this query + * @param options - Required {@link GraphqlOptions} when executing a GraphQL query. * @returns an reference to the named query with the specified impersonation and variables. */ public queryRef( @@ -183,9 +182,7 @@ export class DataConnect { /** * Create a reference to a specific "instance" of a named query. - * @param name Name of query - * @param impersonate Impersonation options for this query - * @param variables Variables passed to this query, may be omitted. + * @param options - Required {@link GraphqlOptions} when executing a GraphQL query. * @returns an reference to the named query with the specified impersonation and variables. */ public queryRef( @@ -206,8 +203,7 @@ export class DataConnect { /** * Create a reference to a specific "instance" of a named mutation. - * @param name Name of mutation - * @param impersonate Impersonation options for this mutation + * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. * @returns an reference to the named mutation with the specified impersonation and variables. */ public mutationRef( @@ -216,19 +212,16 @@ export class DataConnect { /** * Create a reference to a specific "instance" of a named mutation. - * @param name Name of mutation - * @param impersonate Impersonation options for this mutation - * @param variables Variables passed to this mutation, may be omitted. + * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. * @returns an reference to the named mutation with the specified impersonation and variables. */ public mutationRef( options: GraphqlOptions - ): MutationRef; + ): MutationRef; + /** * Create a reference to a specific "instance" of a named mutation. - * @param name Name of mutation - * @param impersonate Impersonation options for this mutation - * @param variables Variables passed to this mutation, may be omitted. + * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. * @returns an reference to the named mutation with the specified impersonation and variables. */ public mutationRef( From bad980836b7c08cd0382c524e7254b524600f862 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Thu, 25 Sep 2025 15:37:35 -0700 Subject: [PATCH 09/20] npm run apidocs --- etc/firebase-admin.data-connect.api.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index 1f688fe44c..511da95935 100644 --- a/etc/firebase-admin.data-connect.api.md +++ b/etc/firebase-admin.data-connect.api.md @@ -28,10 +28,14 @@ export class DataConnect { readonly connectorConfig: ConnectorConfig; executeGraphql(query: string, options?: GraphqlOptions): Promise>; executeGraphqlRead(query: string, options?: GraphqlOptions): Promise>; - executeMutation(options: GraphqlOptions): Promise>; - executeQuery(options: GraphqlOptions): Promise>; insert(tableName: string, variables: Variables): Promise>; insertMany>(tableName: string, variables: Variables): Promise>; + // Warning: (ae-forgotten-export) The symbol "MutationRef" needs to be exported by the entry point index.d.ts + mutationRef(options: GraphqlOptions): MutationRef; + mutationRef(options: GraphqlOptions): MutationRef; + // Warning: (ae-forgotten-export) The symbol "QueryRef" needs to be exported by the entry point index.d.ts + queryRef(options: GraphqlOptions): QueryRef; + queryRef(options: GraphqlOptions): QueryRef; upsert(tableName: string, variables: Variables): Promise>; upsertMany>(tableName: string, variables: Variables): Promise>; } From 6bdef60e7ecadaa70bba195338529be8a9c9953a Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 11:14:06 -0700 Subject: [PATCH 10/20] spread GraphqlOptions arguments in OperationRefs and executeOperation functions --- .../data-connect-api-client-internal.ts | 39 +++---- src/data-connect/data-connect-api.ts | 13 ++- src/data-connect/data-connect.ts | 103 ++++++++++-------- 3 files changed, 88 insertions(+), 67 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 1571d5d2ec..97bc5d7133 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -23,7 +23,7 @@ import { import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; import * as validator from '../utils/validator'; -import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data-connect-api'; +import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions, RefOptions } from './data-connect-api'; const API_VERSION = 'v1'; @@ -152,9 +152,11 @@ export class DataConnectApiClient { * @returns A promise that fulfills with the GraphQL response. */ public async executeQuery( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): Promise> { - return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, options); + return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, name, variables, options); } /** @@ -164,9 +166,11 @@ export class DataConnectApiClient { * @returns A promise that fulfills with the GraphQL response. */ public async executeMutation( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): Promise> { - return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, options); + return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, name, variables, options); } /** @@ -179,24 +183,17 @@ export class DataConnectApiClient { */ private async executeOperationHelper( endpoint: string, - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): Promise> { if ( - typeof options.operationName === 'undefined' || - !validator.isNonEmptyString(options.operationName) + typeof name === 'undefined' || + !validator.isNonEmptyString(name) ) { throw new FirebaseDataConnectError( DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - '`options.operationName` must be a non-empty string.' - ); - } - if ( - typeof options.impersonate === 'undefined' || - !validator.isNonNullObject(options?.impersonate) - ) { - throw new FirebaseDataConnectError( - DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - '`options.impersonate` must be a non-null object.' + '`name` must be a non-empty string.' ); } @@ -208,9 +205,9 @@ export class DataConnectApiClient { } const data = { - ...(options.variables && { variables: options?.variables }), - operationName: options.operationName, - extensions: { impersonate: options.impersonate }, + ...(variables && { variables: variables }), + operationName: name, + extensions: { impersonate: options?.impersonate }, }; const url = await this.getUrl( API_VERSION, diff --git a/src/data-connect/data-connect-api.ts b/src/data-connect/data-connect-api.ts index ea8239458f..a12d1cd434 100644 --- a/src/data-connect/data-connect-api.ts +++ b/src/data-connect/data-connect-api.ts @@ -33,7 +33,6 @@ export interface ConnectorConfig { /** * Name of the Data Connect connector. - * Required for operations that interact with connectors, such as executeQuery and executeMutation. */ connector?: string; } @@ -59,7 +58,6 @@ export interface GraphqlOptions { /** * The name of the GraphQL operation. - * Required for operations that interact with connectors, such as executeQuery and executeMutation. * Required for operations that interact with services, such as executeGraphql, if * `query` contains multiple operations. */ @@ -72,6 +70,17 @@ export interface GraphqlOptions { impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; } +/** + * Interface representing options for OperationRefs. + */ +export interface RefOptions { + /** + * If set, impersonate a request with given Firebase Auth context and evaluate the auth + * policies on the operation. If omitted, bypass any defined auth policies. + */ + impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; +} + /** * Type representing the partial claims of a Firebase Auth token used to evaluate the * Data Connect auth policy. diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index d147e22f28..2e1169e213 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -25,6 +25,7 @@ import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions, + RefOptions, } from './data-connect-api'; export class DataConnectService { @@ -162,35 +163,51 @@ export class DataConnect { } /** @internal */ - public executeQuery(options: GraphqlOptions): Promise> { - return this.client.executeQuery(options); + public executeQuery( + name: string, + variables: Variables, + options?: RefOptions + ): Promise> { + return this.client.executeQuery(name, variables, options); } /** @internal */ - public executeMutation(options: GraphqlOptions): Promise> { - return this.client.executeMutation(options); + public executeMutation( + name: string, + variables: Variables, + options?: RefOptions + ): Promise> { + return this.client.executeMutation(name, variables, options); } /** * Create a reference to a specific "instance" of a named query. - * @param options - Required {@link GraphqlOptions} when executing a GraphQL query. - * @returns an reference to the named query with the specified impersonation and variables. + * @param name - The name of the query. + * @param options - The RefOptions for the query (optional). + * @returns A reference to the named query with the specified impersonation and variables. */ public queryRef( - options: GraphqlOptions + name: string, + options?: RefOptions ): QueryRef; /** * Create a reference to a specific "instance" of a named query. - * @param options - Required {@link GraphqlOptions} when executing a GraphQL query. - * @returns an reference to the named query with the specified impersonation and variables. + * @param name - The name of the query. + * @param variables - The variables for the query. May be optional if the query's variables are optional. + * @param options - The RefOptions for the query (optional). + * @returns A reference to the named query with the specified impersonation and variables. */ public queryRef( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): QueryRef; public queryRef( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): QueryRef { if (!('connector' in this.connectorConfig)){ throw new FirebaseDataConnectError( @@ -198,34 +215,37 @@ export class DataConnect { `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when creating a queryRef.`); } - return new QueryRef(this, options); + return new QueryRef(this, name, variables, options); } /** * Create a reference to a specific "instance" of a named mutation. - * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. - * @returns an reference to the named mutation with the specified impersonation and variables. + * @param name - The name of the mutation. + * @param options - The RefOptions for the mutation (optional). + * @returns A reference to the named mutation with the specified impersonation and variables. */ public mutationRef( - options: GraphqlOptions + name: string, + options?: RefOptions ): MutationRef /** * Create a reference to a specific "instance" of a named mutation. - * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. - * @returns an reference to the named mutation with the specified impersonation and variables. + * @param name - The name of the mutation. + * @param variables - The variables for the mutation. May be optional if the mutation's variables are optional. + * @param options - The RefOptions for the mutation (optional). + * @returns A reference to the named mutation with the specified impersonation and variables. */ public mutationRef( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): MutationRef; - /** - * Create a reference to a specific "instance" of a named mutation. - * @param options - Required {@link GraphqlOptions} when executing a GraphQL mutation. - * @returns an reference to the named mutation with the specified impersonation and variables. - */ public mutationRef( - options: GraphqlOptions + name: string, + variables: Variables, + options?: RefOptions ): MutationRef { if (!('connector' in this.connectorConfig)){ throw new FirebaseDataConnectError( @@ -233,7 +253,7 @@ export class DataConnect { `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when creating a mutationRef.`); } - return new MutationRef(this, options); + return new MutationRef(this, name, variables, options); } } @@ -244,10 +264,16 @@ interface OperationResult { dataConnect: DataConnect; } +/** + * The result of executing a query. + */ export interface QueryResult extends OperationResult { ref: QueryRef; } +/** + * The result of executing a mutation. + */ export interface MutationResult extends OperationResult { ref: MutationRef; } @@ -255,31 +281,20 @@ export interface MutationResult extends OperationResult { constructor( public readonly dataConnect: DataConnect, - public readonly options: GraphqlOptions, - ) { - if (typeof options.operationName === 'undefined') { - throw new FirebaseDataConnectError( - DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - `The 'options.operationName' field must be provided when creating a queryRef - or mutationRef.`); - } - if (typeof options.impersonate === 'undefined') { - throw new FirebaseDataConnectError( - DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, - `The 'options.impersonate' field must be provided when creating a queryRef - or mutationRef.`); - } - } + public readonly name: string, + public readonly variables: Variables, + public readonly options?: RefOptions + ) {} abstract execute(): Promise>; } class QueryRef extends OperationRef { async execute(): Promise> { - const { data } = await this.dataConnect.executeQuery(this.options); + const { data } = await this.dataConnect.executeQuery(this.name, this.variables, this.options); return { ref: this, data: data, - variables: this.options.variables as Variables, + variables: this.variables, dataConnect: this.dataConnect } } @@ -287,11 +302,11 @@ class QueryRef extends OperationRef { class MutationRef extends OperationRef { async execute(): Promise> { - const { data } = await this.dataConnect.executeMutation(this.options) + const { data } = await this.dataConnect.executeMutation(this.name, this.variables, this.options) return { ref: this, data: data, - variables: this.options.variables as Variables, + variables: this.variables, dataConnect: this.dataConnect } } From 798c2dce6039e715616730694c6075c0c73cf4e3 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 13:26:21 -0700 Subject: [PATCH 11/20] convert unit tests to use spread args --- .../data-connect-api-client-internal.spec.ts | 146 +++++++++--------- 1 file changed, 76 insertions(+), 70 deletions(-) diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index 41966e6253..b43c34a61b 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ // TODO: REMOVE THIS /*! * @license * Copyright 2024 Google LLC @@ -27,8 +28,9 @@ import * as mocks from '../../resources/mocks'; import { DATA_CONNECT_ERROR_CODE_MAPPING, DataConnectApiClient, FirebaseDataConnectError } from '../../../src/data-connect/data-connect-api-client-internal'; import { FirebaseApp } from '../../../src/app/firebase-app'; -import { ConnectorConfig, GraphqlOptions } from '../../../src/data-connect'; +import { ConnectorConfig } from '../../../src/data-connect'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils'; +import { RefOptions } from '../../../src/data-connect/data-connect-api'; describe('DataConnectApiClient', () => { @@ -236,33 +238,30 @@ describe('DataConnectApiClient', () => { }); describe('executeQuery', () => { - const unauthenticatedOptions: GraphqlOptions = - { operationName: 'unauthenticatedQuery', impersonate: { unauthenticated: true } }; - const authenticatedOptions: GraphqlOptions = - { operationName: 'authenticatedQuery', impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; + const unauthenticatedOptions: RefOptions = { impersonate: { unauthenticated: true } }; + const authenticatedOptions: RefOptions = { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; it('should reject when no operationName is provided', () => { - apiClient.executeQuery({ impersonate: { unauthenticated: true } }) - .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); - apiClient.executeQuery({ operationName: undefined, impersonate: { unauthenticated: true } }) - .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); - }); - it('should reject when no impersonate object is provided', () => { - apiClient.executeQuery({ operationName: 'queryName' }) - .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); - apiClient.executeQuery({ operationName: 'queryName', impersonate: undefined }) - .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + apiClient.executeQuery( '', undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); + apiClient.executeQuery(undefined as unknown as string, undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); }); + it('should reject when project id is not available', () => { - clientWithoutProjectId.executeQuery(unauthenticatedOptions) - .should.eventually.be.rejectedWith(noProjectId); + clientWithoutProjectId.executeQuery( + 'unauthenticated query', + undefined, + unauthenticatedOptions + ).should.eventually.be.rejectedWith(noProjectId); }); + it('should reject when no connectorId is provided', () => { apiClient = new DataConnectApiClient( { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, app ); - apiClient.executeQuery({ impersonate: { unauthenticated: true } }) + apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) .should.eventually.be.rejectedWith( `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); @@ -273,7 +272,7 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); - return apiClient.executeQuery(unauthenticatedOptions) + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -282,7 +281,7 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .rejects(utils.errorFrom({}, 404)); const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); - return apiClient.executeQuery(unauthenticatedOptions) + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -292,7 +291,7 @@ describe('DataConnectApiClient', () => { .rejects(utils.errorFrom('not json', 404)); const expected = new FirebaseDataConnectError( 'unknown-error', 'Unexpected response with status: 404 and body: not json'); - return apiClient.executeQuery(unauthenticatedOptions) + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -301,7 +300,7 @@ describe('DataConnectApiClient', () => { sandbox .stub(HttpClient.prototype, 'send') .rejects(expected); - return apiClient.executeQuery(unauthenticatedOptions) + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -319,7 +318,11 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery(unauthenticatedOptions).then((resp) => { + return apiClient.executeQuery( + 'unauthenticated query', + undefined, + unauthenticatedOptions + ).then((resp) => { expect(resp.data.users).to.be.not.empty; expect(resp.data.users[0].name).to.be.not.undefined; expect(resp.data.users[0].address).to.be.not.undefined; @@ -332,8 +335,8 @@ describe('DataConnectApiClient', () => { url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, data: { - operationName: unauthenticatedOptions.operationName, - extensions: { impersonate: unauthenticatedOptions.impersonate } + operationName: 'unauthenticated query', + extensions: unauthenticatedOptions } }); }); @@ -342,7 +345,11 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery(authenticatedOptions).then((resp) => { + return apiClient.executeQuery( + 'authenticated query', + undefined, + authenticatedOptions + ).then((resp) => { expect(resp.data.users).to.be.not.empty; expect(resp.data.users[0].name).to.be.not.undefined; expect(resp.data.users[0].address).to.be.not.undefined; @@ -355,7 +362,7 @@ describe('DataConnectApiClient', () => { url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, data: { - operationName: authenticatedOptions.operationName, + operationName: 'authenticated query', extensions: { impersonate: authenticatedOptions.impersonate } } }); @@ -368,49 +375,48 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery(unauthenticatedOptions) - .then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, - headers: EMULATOR_EXPECTED_HEADERS, - data: { - operationName: unauthenticatedOptions.operationName, - extensions: { impersonate: unauthenticatedOptions.impersonate } - } - }); + return apiClient.executeQuery( + 'unauthenticated query', + undefined, + unauthenticatedOptions + ).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated query', + extensions: unauthenticatedOptions + } }); + }); }); }); - const unauthenticatedOptions: GraphqlOptions = - { operationName: 'operationName', impersonate: { unauthenticated: true } }; - const authenticatedOptions: GraphqlOptions = - { operationName: 'operationName', impersonate: { unauthenticated: true } }; + const unauthenticatedOptions: RefOptions = + { impersonate: { unauthenticated: true } }; + const authenticatedOptions: RefOptions = + { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; describe('executeMutation', () => { it('should reject when no operationName is provided', () => { - apiClient.executeMutation({ impersonate: { unauthenticated: true } }) - .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); - apiClient.executeMutation({ operationName: undefined, impersonate: { unauthenticated: true } }) - .should.eventually.be.rejectedWith('`query` must be a non-empty string.'); - }); - it('should reject when no impersonate object is provided', () => { - apiClient.executeMutation({ operationName: 'queryName' }) - .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); - apiClient.executeMutation({ operationName: 'queryName', impersonate: undefined }) - .should.eventually.be.rejectedWith('GraphqlOptions must be a non-null object'); + apiClient.executeMutation('', undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); + apiClient.executeMutation(undefined as unknown as string, undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); }); + it('should reject when project id is not available', () => { - clientWithoutProjectId.executeMutation(unauthenticatedOptions) + clientWithoutProjectId.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejectedWith(noProjectId); }); + it('should reject when no connectorId is provided', () => { apiClient = new DataConnectApiClient( { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, app ); - apiClient.executeMutation({ impersonate: { unauthenticated: true } }) + apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejectedWith( `The 'connectorConfig.connector' field used to instantiate your Data Connect instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); @@ -421,7 +427,7 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -430,7 +436,7 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .rejects(utils.errorFrom({}, 404)); const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -440,7 +446,7 @@ describe('DataConnectApiClient', () => { .rejects(utils.errorFrom('not json', 404)); const expected = new FirebaseDataConnectError( 'unknown-error', 'Unexpected response with status: 404 and body: not json'); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -449,7 +455,7 @@ describe('DataConnectApiClient', () => { sandbox .stub(HttpClient.prototype, 'send') .rejects(expected); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -467,7 +473,7 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .then((resp) => { expect(resp.data.users).to.be.not.empty; expect(resp.data.users[0].name).to.be.not.undefined; @@ -482,8 +488,8 @@ describe('DataConnectApiClient', () => { url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, data: { - operationName: unauthenticatedOptions.operationName, - extensions: { impersonate: unauthenticatedOptions.impersonate } + operationName: 'unauthenticated mutation', + extensions: unauthenticatedOptions } }); }); @@ -492,7 +498,7 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation(authenticatedOptions) + return apiClient.executeMutation('authenticated mutation', undefined, authenticatedOptions) .then((resp) => { expect(resp.data.users).to.be.not.empty; expect(resp.data.users[0].name).to.be.not.undefined; @@ -507,8 +513,8 @@ describe('DataConnectApiClient', () => { url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, data: { - operationName: authenticatedOptions.operationName, - extensions: { impersonate: authenticatedOptions.impersonate } + operationName: 'authenticated mutation', + extensions: authenticatedOptions } }); }); @@ -528,7 +534,7 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('authenticated mutation', undefined, authenticatedOptions) .then((resp) => { expect(resp.data.users).to.be.not.empty; expect(resp.data.users[0].name).to.be.not.undefined; @@ -542,8 +548,8 @@ describe('DataConnectApiClient', () => { url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, data: { - operationName: unauthenticatedOptions.operationName, - extensions: { impersonate: unauthenticatedOptions.impersonate } + operationName: 'authenticated mutation', + extensions: authenticatedOptions } }); }); @@ -554,15 +560,15 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation(unauthenticatedOptions) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EMULATOR_EXPECTED_HEADERS, data: { - operationName: unauthenticatedOptions.operationName, - extensions: { impersonate: unauthenticatedOptions.impersonate } + operationName: 'unauthenticated mutation', + extensions: unauthenticatedOptions } }); }); From 85a6b4b74d76a89a0ee37ed38e4d442522ccb8f5 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 13:46:30 -0700 Subject: [PATCH 12/20] convert integration tests to use spread args --- test/integration/data-connect.spec.ts | 334 +++++++++++--------------- 1 file changed, 140 insertions(+), 194 deletions(-) diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index e788cb14d0..cc73ed16fb 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -16,9 +16,10 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import { getDataConnect, ConnectorConfig, GraphqlOptions } from '../../lib/data-connect/index'; +import { getDataConnect, ConnectorConfig } from '../../lib/data-connect/index'; import firebase from '@firebase/app-compat'; import { apiKey, projectId } from './setup'; +import { RefOptions } from '../../lib/data-connect/data-connect-api'; chai.should(); chai.use(chaiAsPromised); @@ -228,13 +229,13 @@ describe('getDataConnect()', () => { user_deleteMany(all: true) }` - const optsUnauthorizedClaims: GraphqlOptions = { + const optsUnauthorizedClaims: RefOptions = { impersonate: { unauthenticated: true } }; - const optsAuthorizedFredAnonClaims: GraphqlOptions = { + const optsAuthorizedFredAnonClaims: RefOptions = { impersonate: { authClaims: { sub: fredUser.id, @@ -246,7 +247,7 @@ describe('getDataConnect()', () => { } }; - const optsAuthorizedFredClaims: GraphqlOptions = { + const optsAuthorizedFredClaims: RefOptions = { impersonate: { authClaims: { sub: fredUser.id, @@ -254,7 +255,7 @@ describe('getDataConnect()', () => { } }; - const optsAuthorizedFredEmailVerifiedClaims: GraphqlOptions = { + const optsAuthorizedFredEmailVerifiedClaims: RefOptions = { impersonate: { authClaims: { sub: fredUser.id, @@ -263,7 +264,7 @@ describe('getDataConnect()', () => { } }; - const optsNonExistingClaims: GraphqlOptions = { + const optsNonExistingClaims: RefOptions = { impersonate: { authClaims: { sub: 'non-exisiting-id', @@ -475,20 +476,17 @@ describe('getDataConnect()', () => { describe('queryRef()', () => { it("should fail when executing a query which doesn't exist", async () => { return getDataConnect(connectorConfig).queryRef( - { - ...optsUnauthorizedClaims, - operationName: 'DOES_NOT_EXIST!!!' - } + 'DOES_NOT_EXIST!!!', + undefined, + optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); }) it('should execut a query with variables', async () => { const resp = await getDataConnect(connectorConfig).queryRef( - { - ...optsUnauthorizedClaims, - operationName: 'GetUser', - variables: { id: { id: fredUser.id } } - } + 'GetUser', + { id: { id: fredUser.id } }, + optsUnauthorizedClaims, ).execute(); expect(resp.data.user).to.not.be.empty; expect(resp.data.user).to.deep.equal(fredUser); @@ -496,8 +494,10 @@ describe('getDataConnect()', () => { describe('with unauthenticated impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsUnauthorizedClaims, operationName: 'ListUsersPublic' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersPublic', + undefined, + optsUnauthorizedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -507,34 +507,34 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER_ANON)', () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsUnauthorizedClaims, operationName: 'ListUsersUserAnon' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUserAnon', undefined, optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsUnauthorizedClaims, operationName: 'ListUsersUser' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUser', undefined, optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsUnauthorizedClaims, operationName: 'ListUsersUserEmailVerified' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUserEmailVerified', undefined, optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsUnauthorizedClaims, operationName: 'ListUsersNoAccess' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersNoAccess', undefined, optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); }); describe('with authenticated anonymous impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersPublic' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersPublic', undefined, optsAuthorizedFredAnonClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -544,8 +544,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserAnon', undefined, optsAuthorizedFredAnonClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -555,26 +555,26 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUser' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUser', undefined, optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersUserEmailVerified' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersNoAccess' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersNoAccess', undefined, optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredAnonClaims, operationName: 'ListUsersImpersonationAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersImpersonationAnon', undefined, optsAuthorizedFredAnonClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); @@ -584,8 +584,8 @@ describe('getDataConnect()', () => { describe('with authenticated user impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersPublic' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersPublic', undefined, optsAuthorizedFredClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -595,8 +595,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserAnon', undefined, optsAuthorizedFredClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -606,8 +606,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersUser' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUser', undefined, optsAuthorizedFredClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -617,20 +617,20 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersUserEmailVerified' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersNoAccess' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersNoAccess', undefined, optsAuthorizedFredClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredClaims, operationName: 'ListUsersImpersonationAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersImpersonationAnon', undefined, optsAuthorizedFredClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); @@ -640,8 +640,8 @@ describe('getDataConnect()', () => { describe('with authenticated email verified user impersonation', () => { it('should successfully execute a query with @auth(level: PUBLIC)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersPublic' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersPublic', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -651,8 +651,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_ANON)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserAnon', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -662,8 +662,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUser' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUser', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -673,8 +673,8 @@ describe('getDataConnect()', () => { }); it('should successfully execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersUserEmailVerified' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).to.equal(initialState.users.length); @@ -684,14 +684,14 @@ describe('getDataConnect()', () => { }); it('should fail to execute a query with @auth(level: NO_ACCESS)', async () => { - return getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersNoAccess' } + return getDataConnect(connectorConfig).queryRef( + 'ListUsersNoAccess', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { - const resp = await getDataConnect(connectorConfig).queryRef( - { ...optsAuthorizedFredEmailVerifiedClaims, operationName: 'ListUsersImpersonationAnon' } + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersImpersonationAnon', undefined, optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.users).to.not.be.empty; expect(resp.data.users.length).equals(1); @@ -703,10 +703,7 @@ describe('getDataConnect()', () => { describe('mutationRef()', () => { it("should fail when executing a mutation which doesn't exist", async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'DOES_NOT_EXIST!!!' - } + 'DOES_NOT_EXIST!!!', optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/not-found'); }) @@ -714,52 +711,42 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'InsertEmailPublic', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER_ANON)', () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'InsertEmailUserAnon', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER)', async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'InsertEmailUser', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'InsertEmailUserEmailVerified', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsUnauthorizedClaims, - operationName: 'InsertEmailNoAccess', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); }); @@ -768,11 +755,9 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailPublic', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -780,61 +765,49 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailUserAnon', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER)', async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailUser', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailUserEmailVerified', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailNoAccess', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'InsertEmailImpersonation', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; const queryResp = await getDataConnect(connectorConfig).queryRef( - { - ...optsAuthorizedFredAnonClaims, - operationName: 'GetEmail', - variables: { id: insertResp.data.email_insert.id } - } + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredAnonClaims ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); @@ -844,11 +817,9 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailPublic', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -856,11 +827,9 @@ describe('getDataConnect()', () => { it('should fail to execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailUserAnon', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -868,51 +837,41 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: USER)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailUser', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailUserEmailVerified', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/unauthenticated'); }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { return getDataConnect(connectorConfig).mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailNoAccess', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredClaims, - operationName: 'InsertEmailImpersonation', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; const queryResp = await getDataConnect(connectorConfig).queryRef( - { - ...optsAuthorizedFredClaims, - operationName: 'GetEmail', - variables: { id: insertResp.data.email_insert.id } - } + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredClaims ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); @@ -922,11 +881,9 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailPublic', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -934,11 +891,9 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailUserAnon', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -946,11 +901,9 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: USER)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailUser', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); @@ -958,40 +911,33 @@ describe('getDataConnect()', () => { it('should successfully execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', async () => { const resp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailUserEmailVerified', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(resp.data.email_insert.id).to.not.be.undefined; }); it('should fail to execute a mutation with @auth(level: NO_ACCESS)', async () => { return getDataConnect(connectorConfig).mutationRef( - { ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailNoAccess', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); }); it("should use the impersonated user's auth.uid", async () => { const insertResp = await getDataConnect(connectorConfig) .mutationRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'InsertEmailImpersonation', - variables: { id: `email_id_${Math.random() * 1000}` } - } + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(insertResp.data.email_insert.id).to.not.be.undefined; const queryResp = await getDataConnect(connectorConfig).queryRef( - { - ...optsAuthorizedFredEmailVerifiedClaims, - operationName: 'GetEmail', - variables: { id: insertResp.data.email_insert.id } - } + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredEmailVerifiedClaims ).execute(); expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); From 5f343439edf093f686192fd4ffc69ef333926214 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 13:51:56 -0700 Subject: [PATCH 13/20] add executeQuery test cases which do not provide impersonation options, bypassing auth policies --- test/integration/data-connect.spec.ts | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index cc73ed16fb..34385562a5 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -698,6 +698,69 @@ describe('getDataConnect()', () => { expect(resp.data.users[0]).to.deep.equal(fredUser); }); }); + + describe('with no impersonation, bypassing auth policies', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersPublic' + ).execute(); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserAnon' + ).execute(); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUser' + ).execute(); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersUserEmailVerified' + ).execute(); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it('should successfully execute a query with @auth(level: NO_ACCESS)', async () => { + const resp = await getDataConnect(connectorConfig).queryRef( + 'ListUsersNoAccess' + ).execute(); + expect(resp.data.users).to.not.be.empty; + expect(resp.data.users.length).to.equal(initialState.users.length); + resp.data.users.forEach((user) => { + expect(initialState.users).to.deep.include(user); + }); + }); + + it("should fail to use the impersonated user's auth.uid", async () => { + return getDataConnect(connectorConfig).queryRef( + 'ListUsersImpersonation' + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + }); + }); }); describe('mutationRef()', () => { From 088d882e789ea396278c5f4a9e808384be3538f8 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 14:00:19 -0700 Subject: [PATCH 14/20] add executeMutation test cases which do not provide impersonation options, bypassing auth policies --- test/integration/data-connect.spec.ts | 60 +++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index 34385562a5..e6ea26a7f8 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -755,10 +755,10 @@ describe('getDataConnect()', () => { }); }); - it("should fail to use the impersonated user's auth.uid", async () => { + it("should fail to execute a query using the impersonated user's auth.uid", async () => { return getDataConnect(connectorConfig).queryRef( - 'ListUsersImpersonation' - ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/permission-denied'); + 'ListUsersImpersonationAnon' + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); }); }); }); @@ -1005,6 +1005,60 @@ describe('getDataConnect()', () => { expect(queryResp.data.email.from.id).to.equal(fredUser.id); }); }); + + describe('with no impersonation, bypassing auth policies', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .mutationRef( + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` } + ).execute(); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig) + .mutationRef( + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` } + ).execute(); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig) + .mutationRef( + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` } + ).execute(); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: USER_EMAIL_VERIFIED)', async () => { + const resp = await getDataConnect(connectorConfig) + .mutationRef( + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` } + ).execute(); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it('should successfully execute a mutation with @auth(level: NO_ACCESS)', async () => { + const resp = await getDataConnect(connectorConfig) + .mutationRef( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` } + ).execute(); + expect(resp.data.email_insert.id).to.not.be.undefined; + }); + + it("should fail to execute a mutation using the impersonated user's auth.uid", async () => { + return getDataConnect(connectorConfig).mutationRef( + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + ).execute().should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); + }); + }); }); }); }); \ No newline at end of file From 216b3ac157718863a659c9c61ffc2bf479903834 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Fri, 26 Sep 2025 14:03:44 -0700 Subject: [PATCH 15/20] run npm apidocs --- etc/firebase-admin.data-connect.api.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index 511da95935..eefcf7524c 100644 --- a/etc/firebase-admin.data-connect.api.md +++ b/etc/firebase-admin.data-connect.api.md @@ -31,11 +31,12 @@ export class DataConnect { insert(tableName: string, variables: Variables): Promise>; insertMany>(tableName: string, variables: Variables): Promise>; // Warning: (ae-forgotten-export) The symbol "MutationRef" needs to be exported by the entry point index.d.ts - mutationRef(options: GraphqlOptions): MutationRef; - mutationRef(options: GraphqlOptions): MutationRef; + mutationRef(name: string, options?: RefOptions): MutationRef; + mutationRef(name: string, variables: Variables, options?: RefOptions): MutationRef; + // Warning: (ae-forgotten-export) The symbol "RefOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "QueryRef" needs to be exported by the entry point index.d.ts - queryRef(options: GraphqlOptions): QueryRef; - queryRef(options: GraphqlOptions): QueryRef; + queryRef(name: string, options?: RefOptions): QueryRef; + queryRef(name: string, variables: Variables, options?: RefOptions): QueryRef; upsert(tableName: string, variables: Variables): Promise>; upsertMany>(tableName: string, variables: Variables): Promise>; } From fb5a3de47afd3f654cd17afaf83f969cc20d59dc Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Mon, 29 Sep 2025 11:15:10 -0700 Subject: [PATCH 16/20] address try/catch comment --- .../data-connect-api-client-internal.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 97bc5d7133..c3d6a45530 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -136,13 +136,13 @@ export class DataConnectApiClient { ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; const url = await this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint); - return this.makeGqlRequest(url, data) - .then((resp) => { - return resp; - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); + + try { + const resp = await this.makeGqlRequest(url, data); + return resp; + } catch (err: any) { + throw this.toFirebaseError(err); + } } /** @@ -216,13 +216,13 @@ export class DataConnectApiClient { endpoint, this.connectorConfig.connector, ); - return this.makeGqlRequest(url, data) - .then((resp) => { - return resp; - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); + + try { + const resp = await this.makeGqlRequest(url, data); + return resp; + } catch (err: any) { + throw this.toFirebaseError(err); + } } /** From fd4ffb572f463f48a1b34185ea7ea6faf426c18e Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Mon, 29 Sep 2025 11:32:16 -0700 Subject: [PATCH 17/20] address await and reject grouping comment --- .../data-connect-api-client-internal.ts | 2 - .../data-connect-api-client-internal.spec.ts | 520 ++++++++---------- 2 files changed, 240 insertions(+), 282 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index c3d6a45530..5544d11fc7 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -136,7 +136,6 @@ export class DataConnectApiClient { ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; const url = await this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint); - try { const resp = await this.makeGqlRequest(url, data); return resp; @@ -216,7 +215,6 @@ export class DataConnectApiClient { endpoint, this.connectorConfig.connector, ); - try { const resp = await this.makeGqlRequest(url, data); return resp; diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index b43c34a61b..b27db4f944 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -116,80 +116,82 @@ describe('DataConnectApiClient', () => { }); describe('executeGraphql', () => { - it('should reject when project id is not available', () => { - return clientWithoutProjectId.executeGraphql('query', {}) - .should.eventually.be.rejectedWith(noProjectId); - }); - - it('should throw an error if query is not a non-empty string', async () => { - await expect(apiClient.executeGraphql('')).to.be.rejectedWith( - FirebaseDataConnectError, - '`query` must be a non-empty string.' - ); - await expect(apiClient.executeGraphql(undefined as any)).to.be.rejectedWith( - FirebaseDataConnectError, - '`query` must be a non-empty string.' - ); - }); + describe('should reject with an appropriate error response on failure', () => { + it('should reject when project id is not available', () => { + return clientWithoutProjectId.executeGraphql('query', {}) + .should.eventually.be.rejectedWith(noProjectId); + }); - const invalidQueries = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; - invalidQueries.forEach((invalidQuery) => { - it('should throw given a non-string query: ' + JSON.stringify(invalidQuery), async () => { - await expect(apiClient.executeGraphql(invalidQuery as any)).to.be.rejectedWith( + it('should throw an error if query is not a non-empty string', async () => { + await expect(apiClient.executeGraphql('')).to.be.rejectedWith( FirebaseDataConnectError, '`query` must be a non-empty string.' ); - }); - }); - - const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop]; - invalidOptions.forEach((invalidOption) => { - it('should throw given an invalid options object: ' + JSON.stringify(invalidOption), async () => { - await expect(apiClient.executeGraphql('query', invalidOption as any)).to.be.rejectedWith( + await expect(apiClient.executeGraphql(undefined as any)).to.be.rejectedWith( FirebaseDataConnectError, - 'GraphqlOptions must be a non-null object' + '`query` must be a non-empty string.' ); }); - }); - it('should reject when a full platform error response is received', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); - const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); - return apiClient.executeGraphql('query', {}) - .should.eventually.be.rejected.and.deep.include(expected); - }); + const invalidQueries = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidQueries.forEach((invalidQuery) => { + it('should throw given a non-string query: ' + JSON.stringify(invalidQuery), async () => { + await expect(apiClient.executeGraphql(invalidQuery as any)).to.be.rejectedWith( + FirebaseDataConnectError, + '`query` must be a non-empty string.' + ); + }); + }); - it('should reject with unknown-error when error code is not present', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom({}, 404)); - const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); - return apiClient.executeGraphql('query', {}) - .should.eventually.be.rejected.and.deep.include(expected); - }); + const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop]; + invalidOptions.forEach((invalidOption) => { + it('should throw given an invalid options object: ' + JSON.stringify(invalidOption), async () => { + await expect(apiClient.executeGraphql('query', invalidOption as any)).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions must be a non-null object' + ); + }); + }); - it('should reject with unknown-error for non-json response', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom('not json', 404)); - const expected = new FirebaseDataConnectError( - 'unknown-error', 'Unexpected response with status: 404 and body: not json'); - return apiClient.executeGraphql('query', {}) - .should.eventually.be.rejected.and.deep.include(expected); - }); + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClient.executeGraphql('query', {}) + .should.eventually.be.rejected.and.deep.include(expected); + }); - it('should reject when rejected with a FirebaseDataConnectError', () => { - const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(expected); - return apiClient.executeGraphql('query', {}) - .should.eventually.be.rejected.and.deep.include(expected); + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClient.executeGraphql('query', {}) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.executeGraphql('query', {}) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClient.executeGraphql('query', {}) + .should.eventually.be.rejected.and.deep.include(expected); + }); }); - it('should resolve with the GraphQL response on success', () => { + it('should resolve with the GraphQL response on success', async () => { interface UsersResponse { users: [ user: { @@ -202,38 +204,34 @@ describe('DataConnectApiClient', () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeGraphql('query', {}) - .then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, - headers: EXPECTED_HEADERS, - data: { query: 'query' } - }); - }); + const resp = await apiClient.executeGraphql('query', {}); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, + headers: EXPECTED_HEADERS, + data: { query: 'query' } + }); }); - it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + it('should use DATA_CONNECT_EMULATOR_HOST if set', async () => { process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeGraphql('query', {}) - .then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, - headers: EMULATOR_EXPECTED_HEADERS, - data: { query: 'query' } - }); - }); + await apiClient.executeGraphql('query', {}); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { query: 'query' } + }); }); }); @@ -241,67 +239,69 @@ describe('DataConnectApiClient', () => { const unauthenticatedOptions: RefOptions = { impersonate: { unauthenticated: true } }; const authenticatedOptions: RefOptions = { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; - it('should reject when no operationName is provided', () => { - apiClient.executeQuery( '', undefined, unauthenticatedOptions) - .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); - apiClient.executeQuery(undefined as unknown as string, undefined, unauthenticatedOptions) - .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); - }); + describe('should reject with an appropriate error response on failure', () => { + it('should reject when no operationName is provided', () => { + apiClient.executeQuery( '', undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); + apiClient.executeQuery(undefined as unknown as string, undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith('`name` must be a non-empty string.'); + }); - it('should reject when project id is not available', () => { - clientWithoutProjectId.executeQuery( - 'unauthenticated query', - undefined, - unauthenticatedOptions - ).should.eventually.be.rejectedWith(noProjectId); - }); + it('should reject when project id is not available', () => { + clientWithoutProjectId.executeQuery( + 'unauthenticated query', + undefined, + unauthenticatedOptions + ).should.eventually.be.rejectedWith(noProjectId); + }); - it('should reject when no connectorId is provided', () => { - apiClient = new DataConnectApiClient( - { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, - app - ); - apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) - .should.eventually.be.rejectedWith( - `The 'connectorConfig.connector' field used to instantiate your Data Connect - instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); - }); + it('should reject when no connectorId is provided', () => { + apiClient = new DataConnectApiClient( + { location: connectorConfig.location, serviceId: connectorConfig.serviceId }, + app + ); + apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) + .should.eventually.be.rejectedWith( + `The 'connectorConfig.connector' field used to instantiate your Data Connect + instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`); + }); - it('should reject when a full platform error response is received', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); - const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); - return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) - .should.eventually.be.rejected.and.deep.include(expected); - }); + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); - it('should reject with unknown-error when error code is not present', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom({}, 404)); - const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); - return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) - .should.eventually.be.rejected.and.deep.include(expected); - }); + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); - it('should reject with unknown-error for non-json response', () => { - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(utils.errorFrom('not json', 404)); - const expected = new FirebaseDataConnectError( - 'unknown-error', 'Unexpected response with status: 404 and body: not json'); - return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) - .should.eventually.be.rejected.and.deep.include(expected); - }); + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); - it('should reject when rejected with a FirebaseDataConnectError', () => { - const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); - sandbox - .stub(HttpClient.prototype, 'send') - .rejects(expected); - return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) - .should.eventually.be.rejected.and.deep.include(expected); + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClient.executeQuery('unauthenticated query', undefined, unauthenticatedOptions) + .should.eventually.be.rejected.and.deep.include(expected); + }); }); describe('should resolve with the GraphQL response on success', () => { @@ -314,81 +314,79 @@ describe('DataConnectApiClient', () => { } ]; } - it('for an unauthenticated request', () => { + it('for an unauthenticated request', async () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery( + const resp = await apiClient.executeQuery( 'unauthenticated query', undefined, unauthenticatedOptions - ).then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, - headers: EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated query', - extensions: unauthenticatedOptions - } - }); + ); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated query', + extensions: unauthenticatedOptions + } }); }); - it('for an authenticated request', () => { + + it('for an authenticated request', async () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery( + const resp = await apiClient.executeQuery( 'authenticated query', undefined, authenticatedOptions - ).then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, - headers: EXPECTED_HEADERS, - data: { - operationName: 'authenticated query', - extensions: { impersonate: authenticatedOptions.impersonate } - } - }); + ); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EXPECTED_HEADERS, + data: { + operationName: 'authenticated query', + extensions: { impersonate: authenticatedOptions.impersonate } + } }); }); }); - it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + it('should use DATA_CONNECT_EMULATOR_HOST if set', async () => { process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeQuery( + await apiClient.executeQuery( 'unauthenticated query', undefined, unauthenticatedOptions - ).then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, - headers: EMULATOR_EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated query', - extensions: unauthenticatedOptions - } - }); + ); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated query', + extensions: unauthenticatedOptions + } }); }); }); @@ -469,109 +467,71 @@ describe('DataConnectApiClient', () => { } ]; } - it('for an unauthenticated request', () => { + + it('for an unauthenticated request', async () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) - .then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // eslint-disable-next-line max-len - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, - headers: EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated mutation', - extensions: unauthenticatedOptions - } - }); - }); + const resp = await apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // eslint-disable-next-line max-len + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated mutation', + extensions: unauthenticatedOptions + } + }); }); - it('for an authenticated request', () => { + + it('for an authenticated request', async () => { const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation('authenticated mutation', undefined, authenticatedOptions) - .then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // eslint-disable-next-line max-len - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, - headers: EXPECTED_HEADERS, - data: { - operationName: 'authenticated mutation', - extensions: authenticatedOptions - } - }); - }); - }); - }); - - it('should resolve with the GraphQL response on success for an authenticated request', () => { - interface UsersResponse { - users: [ - user: { - id: string; - name: string; - address: string; + const resp = await apiClient.executeMutation('authenticated mutation', undefined, authenticatedOptions); + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // eslint-disable-next-line max-len + // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EXPECTED_HEADERS, + data: { + operationName: 'authenticated mutation', + extensions: authenticatedOptions } - ]; - } - const stub = sandbox - .stub(HttpClient.prototype, 'send') - .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation('authenticated mutation', undefined, authenticatedOptions) - .then((resp) => { - expect(resp.data.users).to.be.not.empty; - expect(resp.data.users[0].name).to.be.not.undefined; - expect(resp.data.users[0].address).to.be.not.undefined; - expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, - headers: EXPECTED_HEADERS, - data: { - operationName: 'authenticated mutation', - extensions: authenticatedOptions - } - }); }); + }); }); - it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + it('should use DATA_CONNECT_EMULATOR_HOST if set', async () => { process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; const stub = sandbox .stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom(TEST_RESPONSE, 200)); - return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) - .then(() => { - expect(stub).to.have.been.calledOnce.and.calledWith({ - method: 'POST', - url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, - headers: EMULATOR_EXPECTED_HEADERS, - data: { - operationName: 'unauthenticated mutation', - extensions: unauthenticatedOptions - } - }); - }); + await apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated mutation', + extensions: unauthenticatedOptions + } + }); }); }); }); From b118905f88e2bef0076efbbb4c0d10d10e59fe9e Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Mon, 29 Sep 2025 12:07:34 -0700 Subject: [PATCH 18/20] address getUrl comments --- .../data-connect-api-client-internal.ts | 103 +++++++++++++----- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 5544d11fc7..18cc5be9dd 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -56,6 +56,27 @@ const DATA_CONNECT_CONFIG_HEADERS = { 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` }; +/** + * URL params for requests to an endpoint under services: + * .../services/{serviceId}:endpoint + */ +interface ServicesUrlParams { + version: string; + projectId: string; + locationId: string; + serviceId: string; + endpointId: string; + host?: string; // Present only when using the emulator +} + +/** + * URL params for requests to an endpoint under connectors: + * .../services/{serviceId}/connectors/{connectorId}:endpoint + */ +interface ConnectorsUrlParams extends ServicesUrlParams { + connectorId: string; +} + /** * Class that facilitates sending requests to the Firebase Data Connect backend API. * @@ -135,7 +156,12 @@ export class DataConnectApiClient { ...(options?.operationName && { operationName: options?.operationName }), ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; - const url = await this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint); + const url = await this.getServicesUrl( + API_VERSION, + this.connectorConfig.location, + this.connectorConfig.serviceId, + endpoint + ); try { const resp = await this.makeGqlRequest(url, data); return resp; @@ -208,12 +234,12 @@ export class DataConnectApiClient { operationName: name, extensions: { impersonate: options?.impersonate }, }; - const url = await this.getUrl( + const url = await this.getConnectorsUrl( API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, - endpoint, this.connectorConfig.connector, + endpoint, ); try { const resp = await this.makeGqlRequest(url, data); @@ -224,52 +250,69 @@ export class DataConnectApiClient { } /** - * Constructs the URL for a Data Connect backend request. - * - * If no connectorId is provided, will direct the request to an endpoint under services: - * .../services/{serviceId}:endpoint + * Constructs the URL for a Data Connect request to a service endpoint. + * + * @param version - The API version. + * @param locationId - The location of the Data Connect service. + * @param serviceId - The ID of the Data Connect service. + * @param endpointId - The endpoint to call. + * @returns A promise which resolves to the formatted URL string. + */ + private async getServicesUrl( + version: string, + locationId: string, + serviceId: string, + endpointId: string, + ): Promise { + const projectId = await this.getProjectId(); + const params: ServicesUrlParams = { + version, + projectId, + locationId, + serviceId, + endpointId, + }; + let urlFormat = FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT; + if (useEmulator()) { + urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT; + params.host = emulatorHost(); + } + return utils.formatString(urlFormat, params); + } + + /** + * Constructs the URL for a Data Connect request to a connector endpoint. * - * If connectorId is provided, will direct the request to an endpoint under connectors: - * .../services/{serviceId}/connectors/{connectorId}:endpoint - * * @param version - The API version. * @param locationId - The location of the Data Connect service. * @param serviceId - The ID of the Data Connect service. + * @param connectorId - The ID of the Connector. * @param endpointId - The endpoint to call. - * @param connectorId - The ID of the connector, if applicable. - * @returns A promise that fulfills with the constructed URL. + * @returns A promise which resolves to the formatted URL string. + */ - private async getUrl( + private async getConnectorsUrl( version: string, locationId: string, serviceId: string, + connectorId: string, endpointId: string, - connectorId?: string, ): Promise { const projectId = await this.getProjectId(); - const urlParams = { + const params: ConnectorsUrlParams = { version, projectId, locationId, serviceId, + connectorId, endpointId, - connectorId }; - let urlFormat: string; + let urlFormat = FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT; if (useEmulator()) { - (urlParams as any).host = emulatorHost(); - urlFormat = connectorId === undefined || connectorId === '' - ? FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT - : FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT; - } else { - urlFormat = connectorId === undefined || connectorId === '' - ? FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT - : FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT; - } - if (connectorId) { - (urlParams as any).connectorId = connectorId; - } - return utils.formatString(urlFormat, urlParams); + urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT; + params.host = emulatorHost(); + } + return utils.formatString(urlFormat, params); } private getProjectId(): Promise { From cf72f383d6c59d0718128b17256afe2f84870237 Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Mon, 29 Sep 2025 12:17:16 -0700 Subject: [PATCH 19/20] address insecureReason comment --- .../dataconnect/dataconnect/my-connector/queries.gql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/dataconnect/dataconnect/my-connector/queries.gql b/test/integration/dataconnect/dataconnect/my-connector/queries.gql index 9c1a403186..b4c3c92213 100644 --- a/test/integration/dataconnect/dataconnect/my-connector/queries.gql +++ b/test/integration/dataconnect/dataconnect/my-connector/queries.gql @@ -34,7 +34,7 @@ query ListUsersNoAccess @auth(level: NO_ACCESS) { address } } -query ListUsersImpersonationAnon @auth(level: USER_ANON, insecureReason: "test") { +query ListUsersImpersonationAnon @auth(level: USER_ANON) { users(where: { id: { eq_expr: "auth.uid" } }) { id name From 43f223d0723fae28c6c6c743a60c91a36be0a3fd Mon Sep 17 00:00:00 2001 From: stephenarosaj Date: Tue, 30 Sep 2025 14:35:17 -0700 Subject: [PATCH 20/20] convert autopush resources to prod --- .../data-connect-api-client-internal.ts | 7 ++--- test/integration/data-connect.spec.ts | 5 +--- .../dataconnect/dataconnect/dataconnect.yaml | 10 ++----- .../data-connect-api-client-internal.spec.ts | 27 ++++--------------- 4 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 18cc5be9dd..054c47c399 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -27,16 +27,13 @@ import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions, RefOptions } f const API_VERSION = 'v1'; -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -// TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! /** The Firebase Data Connect backend service URL format. */ const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT = - 'https://autopush-firebasedataconnect.sandbox.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; + 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; /** The Firebase Data Connect backend connector URL format. */ const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT = - 'https://autopush-firebasedataconnect.sandbox.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}'; + 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}'; /** Firebase Data Connect service URL format when using the Data Connect emulator. */ const FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT = diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index e6ea26a7f8..01316a7c66 100644 --- a/test/integration/data-connect.spec.ts +++ b/test/integration/data-connect.spec.ts @@ -114,10 +114,7 @@ interface DeleteResponse { } const connectorConfig: ConnectorConfig = { - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO us-west2 - us-central1 IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - location: 'us-central1', + location: 'us-west2', serviceId: 'my-service', connector: 'my-connector' }; diff --git a/test/integration/dataconnect/dataconnect/dataconnect.yaml b/test/integration/dataconnect/dataconnect/dataconnect.yaml index 75bad94962..2f5b815b88 100644 --- a/test/integration/dataconnect/dataconnect/dataconnect.yaml +++ b/test/integration/dataconnect/dataconnect/dataconnect.yaml @@ -1,19 +1,13 @@ specVersion: "v1" serviceId: "my-service" -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# TODO: CHANGE THIS BACK TO us-west2 - us-central1 IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD -# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -location: "us-central1" +location: "us-west2" schema: source: "./schema" datasource: postgresql: database: "my-database" cloudSql: - # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - # TODO: CHANGE THIS BACK TO my-instance - my-instance-autopush IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - instanceId: "my-instance-autopush" + instanceId: "my-instance" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./my-connector"] diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index b27db4f944..3639d4d972 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -211,10 +211,7 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, headers: EXPECTED_HEADERS, data: { query: 'query' } }); @@ -329,10 +326,7 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, data: { operationName: 'unauthenticated query', @@ -356,10 +350,7 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateQuery`, headers: EXPECTED_HEADERS, data: { operationName: 'authenticated query', @@ -479,11 +470,7 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // eslint-disable-next-line max-len - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, data: { operationName: 'unauthenticated mutation', @@ -503,11 +490,7 @@ describe('DataConnectApiClient', () => { expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'POST', - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // eslint-disable-next-line max-len - // TODO: CHANGE THIS BACK TO PROD - AUTOPUSH IS ONLY USED FOR LOCAL TESTING BEFORE CHANGES PROPAGATE TO PROD - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - url: `https://autopush-firebasedataconnect.sandbox.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}/connectors/${connectorConfig.connector}:impersonateMutation`, headers: EXPECTED_HEADERS, data: { operationName: 'authenticated mutation',