diff --git a/etc/firebase-admin.data-connect.api.md b/etc/firebase-admin.data-connect.api.md index 8a1e7a042c..4ac9e170cd 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,10 @@ export class DataConnect { readonly connectorConfig: ConnectorConfig; executeGraphql(query: string, options?: GraphqlOptions): Promise>; executeGraphqlRead(query: string, options?: GraphqlOptions): Promise>; + executeMutation(name: string, options?: OperationOptions): Promise>; + executeMutation(name: string, variables: Variables, options?: OperationOptions): Promise>; + executeQuery(name: string, options?: OperationOptions): Promise>; + executeQuery(name: string, variables: Variables, options?: OperationOptions): Promise>; insert(tableName: string, variables: Variables): Promise>; insertMany>(tableName: string, variables: Variables): Promise>; upsert(tableName: string, variables: Variables): Promise>; @@ -38,6 +43,11 @@ export interface ExecuteGraphqlResponse { data: GraphqlResponse; } +// @public +export interface ExecuteOperationResponse { + data: GraphqlResponse; +} + // @public export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect; @@ -60,4 +70,9 @@ export interface ImpersonateUnauthenticated { unauthenticated: true; } +// @public +export interface OperationOptions { + impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; +} + ``` diff --git a/package-lock.json b/package-lock.json index 5d6cea5157..d55aa7d9d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "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/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index 79a72431ac..41d3714a6c 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -23,21 +23,32 @@ 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, OperationOptions } from './data-connect-api'; 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}'; +/** The Firebase Data Connect backend service URL format. */ +const FIREBASE_DATA_CONNECT_SERVICES_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 = +/** The Firebase Data Connect backend connector URL format. */ +const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT = + '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 = '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'; + function getHeaders(isUsingGen: boolean): { [key: string]: string } { const headerValue = { @@ -50,6 +61,27 @@ function getHeaders(isUsingGen: boolean): { [key: string]: string } { return headerValue; } +/** + * 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. * @@ -106,6 +138,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, @@ -129,52 +170,163 @@ 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: getHeaders(this.isUsingGen), - 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, - }); - }) - .then((resp) => { - return resp; - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); + const url = await this.getServicesUrl( + API_VERSION, + this.connectorConfig.location, + this.connectorConfig.serviceId, + endpoint + ); + try { + const resp = await this.makeGqlRequest(url, data); + return resp; + } catch (err: any) { + throw this.toFirebaseError(err); + } } - 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( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise> { + return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, name, variables, 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( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise> { + return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, name, variables, 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, + name: string, + variables: Variables, + options?: OperationOptions + ): Promise> { + if ( + typeof name === 'undefined' || + !validator.isNonEmptyString(name) + ) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + '`name` must be a non-empty string.' + ); + } + + 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 = { + ...(variables && { variables: variables }), + operationName: name, + extensions: { impersonate: options?.impersonate }, + }; + const url = await this.getConnectorsUrl( + API_VERSION, + this.connectorConfig.location, + this.connectorConfig.serviceId, + this.connectorConfig.connector, + endpoint, + ); + try { + const resp = await this.makeGqlRequest(url, data); + return resp; + } catch (err: any) { + throw this.toFirebaseError(err); + } + } + + /** + * 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. + * + * @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. + * @returns A promise which resolves to the formatted URL string. + + */ + private async getConnectorsUrl( + version: string, + locationId: string, + serviceId: string, + connectorId: string, + endpointId: string, + ): Promise { + const projectId = await this.getProjectId(); + const params: ConnectorsUrlParams = { + version, + projectId, + locationId, + serviceId, + connectorId, + endpointId, + }; + let urlFormat = FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT; + if (useEmulator()) { + urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT; + params.host = emulatorHost(); + } + return utils.formatString(urlFormat, params); } private getProjectId(): Promise { @@ -195,6 +347,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: getHeaders(this.isUsingGen), + 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..bd25d493b2 100644 --- a/src/data-connect/data-connect-api.ts +++ b/src/data-connect/data-connect-api.ts @@ -30,10 +30,15 @@ export interface ConnectorConfig { * Service ID of the Data Connect service. */ serviceId: string; + + /** + * Name of the Data Connect connector. + */ + connector?: string; } /** - * Interface representing GraphQL response. + * Interface representing ExecuteGraphQL response. */ export interface ExecuteGraphqlResponse { /** @@ -43,7 +48,17 @@ export interface ExecuteGraphqlResponse { } /** - * Interface representing GraphQL options. + * Interface representing ExecuteOperation response. + */ +export interface ExecuteOperationResponse { + /** + * Data payload of the GraphQL response. + */ + data: GraphqlResponse; +} + +/** + * Interface representing GraphQL options for executing arbitrary GraphQL operations. */ export interface GraphqlOptions { /** @@ -52,7 +67,9 @@ 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 services, such as executeGraphql, if + * `query` contains multiple operations. */ operationName?: string; @@ -63,6 +80,17 @@ export interface GraphqlOptions { impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated; } +/** + * Interface representing options for executing defined operations. + */ +export interface OperationOptions { + /** + * 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 366b756327..3493e2db4e 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -21,7 +21,9 @@ import { DataConnectApiClient } from './data-connect-api-client-internal'; import { ConnectorConfig, ExecuteGraphqlResponse, + ExecuteOperationResponse, GraphqlOptions, + OperationOptions, } from './data-connect-api'; export class DataConnectService { @@ -96,13 +98,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, @@ -165,4 +167,76 @@ export class DataConnect { ): Promise> { return this.client.upsertMany(tableName, variables); } -} + + /** + * Executes a GraphQL query. The query must be defined in your Data Connect GraphQL files. + * Optionally, you may provide auth impersonation. + * + * @param name - The name of the defined query to execute. + * @param options - The GraphQL options, must include operationName and impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public executeQuery( + name: string, + options?: OperationOptions + ): Promise>; + + /** + * Executes a GraphQL query. The query must be defined in your Data Connect GraphQL files. + * Optionally, you may provide auth impersonation. + * + * @param name - The name of the defined query to execute. + * @param options - The GraphQL options, must include operationName and impersonation details. + * @param variables - The variables for the query. May be optional if the query's variables are optional. + * @returns A promise that fulfills with the GraphQL response. + */ + public executeQuery( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise>; + + public executeQuery( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise> { + return this.client.executeQuery(name, variables, options); + } + + /** + * Executes a GraphQL mutation. The mutation must be defined in your Data Connect GraphQL files. + * Optionally, you may provide auth impersonation. + * + * @param name - The name of the defined mutation to execute. + * @param options - The GraphQL options, must include operationName and impersonation details. + * @returns A promise that fulfills with the GraphQL response. + */ + public executeMutation( + name: string, + options?: OperationOptions + ): Promise>; + + /** + * Executes a GraphQL mutation. The mutation must be defined in your Data Connect GraphQL files. + * Optionally, you may provide auth impersonation. + * + * @param name - The name of the defined mutation to execute. + * @param options - The GraphQL options, must include operationName and impersonation details. + * @param variables - The variables for the mutation. May be optional if the mutation's variables are optional. + * @returns A promise that fulfills with the GraphQL response. + */ + public executeMutation( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise>; + + public executeMutation( + name: string, + variables: Variables, + options?: OperationOptions + ): Promise> { + return this.client.executeMutation(name, variables, options); + } +} \ No newline at end of file diff --git a/src/data-connect/index.ts b/src/data-connect/index.ts index 43ca313c90..79297deba1 100644 --- a/src/data-connect/index.ts +++ b/src/data-connect/index.ts @@ -29,10 +29,12 @@ import { ConnectorConfig } from './data-connect-api'; export { GraphqlOptions, ExecuteGraphqlResponse, + ExecuteOperationResponse, ConnectorConfig, ImpersonateAuthenticated, ImpersonateUnauthenticated, - AuthClaims + AuthClaims, + OperationOptions, } from './data-connect-api' export { DataConnect, @@ -51,6 +53,7 @@ export { * const connectorConfig: ConnectorConfig = { * location: 'us-west2', * serviceId: 'my-service', + * connectorName: 'my-connector', * }; * * // Get the `DataConnect` service for the default app diff --git a/test/integration/data-connect.spec.ts b/test/integration/data-connect.spec.ts index 4cf74549cf..68451fab9a 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 { OperationOptions } from '../../lib/data-connect/data-connect-api'; chai.should(); chai.use(chaiAsPromised); @@ -44,11 +45,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 +60,10 @@ type Email = { id: string; }; +interface GetUserVariables { + id: { id: string; }; +} + interface GetUserResponse { user: User; } @@ -75,16 +80,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 { @@ -95,6 +116,7 @@ interface DeleteResponse { const connectorConfig: ConnectorConfig = { location: 'us-west2', serviceId: 'my-service', + connector: 'my-connector' }; const fredUser = { id: 'fred_id', address: '32 Elm St.', name: 'Fred' } @@ -204,223 +226,836 @@ 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: OperationOptions = { + impersonate: { + unauthenticated: true + } + }; - 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 optsAuthorizedFredAnonClaims: OperationOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + firebase: { + identities: { who: 'me' }, + sign_in_provider: 'anonymous' + } + } + } + }; + + const optsAuthorizedFredClaims: OperationOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + } + } + }; + + const optsAuthorizedFredEmailVerifiedClaims: OperationOptions = { + impersonate: { + authClaims: { + sub: fredUser.id, + email_verified: true + } + } + }; + + const optsNonExistingClaims: OperationOptions = { + 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 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() 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); + 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 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('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() 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('operation ref API', () => { + describe('queryRef()', () => { + it("should fail when executing a query which doesn't exist", async () => { + return getDataConnect(connectorConfig).executeQuery( + 'DOES_NOT_EXIST!!!', + undefined, + optsUnauthorizedClaims + ).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).executeQuery( + 'GetUser', + { id: { id: fredUser.id } }, + optsUnauthorizedClaims, + ); + 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( + 'ListUsersPublic', + undefined, + 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('should fail to execute a query with @auth(level: USER_ANON)', () => { + return getDataConnect(connectorConfig).executeQuery( + 'ListUsersUserAnon', undefined, optsUnauthorizedClaims + ).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( + 'ListUsersUser', undefined, optsUnauthorizedClaims + ).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( + 'ListUsersUserEmailVerified', undefined, optsUnauthorizedClaims + ).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( + 'ListUsersNoAccess', undefined, optsUnauthorizedClaims + ).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('with authenticated anonymous impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersPublic', undefined, optsAuthorizedFredAnonClaims + ); + 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( + 'ListUsersUserAnon', undefined, optsAuthorizedFredAnonClaims + ); + 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)', async () => { + return getDataConnect(connectorConfig).executeQuery( + 'ListUsersUser', undefined, optsAuthorizedFredAnonClaims + ).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( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredAnonClaims + ).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( + 'ListUsersNoAccess', undefined, optsAuthorizedFredAnonClaims + ).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( + 'ListUsersImpersonationAnon', undefined, optsAuthorizedFredAnonClaims + ); + 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() 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 user impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersPublic', undefined, 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); + }); }); - }); - 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 successfully execute a query with @auth(level: USER_ANON)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersUserAnon', undefined, 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('should successfully execute a query with @auth(level: USER)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersUser', undefined, 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('should fail to execute a query with @auth(level: USER_EMAIL_VERIFIED)', async () => { + return getDataConnect(connectorConfig).executeQuery( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredClaims + ).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( + 'ListUsersNoAccess', undefined, optsAuthorizedFredClaims + ).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( + 'ListUsersImpersonationAnon', undefined, 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); }); }); - 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 email verified user impersonation', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersPublic', undefined, optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'ListUsersUserAnon', undefined, optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'ListUsersUser', undefined, optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'ListUsersUserEmailVerified', undefined, optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'ListUsersNoAccess', undefined, optsAuthorizedFredEmailVerifiedClaims + ).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( + 'ListUsersImpersonationAnon', undefined, optsAuthorizedFredEmailVerifiedClaims + ); + 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 no impersonation, bypassing auth policies', () => { + it('should successfully execute a query with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + '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( + '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( + '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( + '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 successfully execute a query with @auth(level: NO_ACCESS)', async () => { + const resp = await getDataConnect(connectorConfig).executeQuery( + 'ListUsersNoAccess' + ); + 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 using the impersonated user's auth.uid", async () => { + return getDataConnect(connectorConfig).executeQuery( + 'ListUsersImpersonationAnon' + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); + }); + }); }); - 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('mutationRef()', () => { + it("should fail when executing a mutation which doesn't exist", async () => { + return getDataConnect(connectorConfig).executeMutation( + 'DOES_NOT_EXIST!!!', optsUnauthorizedClaims + ).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( + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims + ); + 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( + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims + ).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( + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims + ).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( + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims + ).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( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsUnauthorizedClaims + ).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.has.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( + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ); + 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( + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ); + 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( + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ).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( + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ).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( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ).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( + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredAnonClaims + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredAnonClaims + ); + 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 user impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ); + 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( + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ); + 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( + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ); + 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( + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ).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( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ).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( + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredClaims + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredClaims + ); + expect(queryResp.data.email.from.id).to.equal(fredUser.id); + }); + }); + + describe('with authenticated email verified user impersonation', () => { + it('should successfully execute a mutation with @auth(level: PUBLIC)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + 'InsertEmailPublic', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'InsertEmailUserAnon', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'InsertEmailUser', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'InsertEmailUserEmailVerified', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ); + 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( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ).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( + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + optsAuthorizedFredEmailVerifiedClaims + ); + expect(insertResp.data.email_insert.id).to.not.be.undefined; + const queryResp = await getDataConnect(connectorConfig).executeQuery( + 'GetEmail', + { id: insertResp.data.email_insert.id }, + optsAuthorizedFredEmailVerifiedClaims + ); + 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) + .executeMutation( + 'InsertEmailPublic', + { 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( + 'InsertEmailUserAnon', + { 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( + 'InsertEmailUser', + { 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( + 'InsertEmailUserEmailVerified', + { 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: NO_ACCESS)', async () => { + const resp = await getDataConnect(connectorConfig) + .executeMutation( + 'InsertEmailNoAccess', + { id: `email_id_${Math.random() * 1000}` } + ); + 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).executeMutation( + 'InsertEmailImpersonation', + { id: `email_id_${Math.random() * 1000}` }, + ).should.eventually.be.rejected.and.have.property('code', 'data-connect/query-error'); + }); + }); }); }); -}); +}); \ No newline at end of file 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..b4c3c92213 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) { 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 } } @@ -59,7 +60,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 67c1541cd6..3bc53ce647 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 @@ -29,6 +30,7 @@ import { DATA_CONNECT_ERROR_CODE_MAPPING, DataConnectApiClient, FirebaseDataConn import { FirebaseApp } from '../../../src/app/firebase-app'; import { ConnectorConfig } from '../../../src/data-connect'; import { getMetricsHeader, getSdkVersion } from '../../../src/utils'; +import { OperationOptions } from '../../../src/data-connect/data-connect-api'; describe('DataConnectApiClient', () => { @@ -75,6 +77,7 @@ describe('DataConnectApiClient', () => { const connectorConfig: ConnectorConfig = { location: 'us-west2', serviceId: 'my-service', + connector: 'my-connector', }; const clientWithoutProjectId = new DataConnectApiClient( @@ -119,48 +122,307 @@ describe('DataConnectApiClient', () => { }); describe('executeGraphql', () => { - it('should reject when project id is not available', () => { - return clientWithoutProjectId.executeGraphql('query', {}) - .should.eventually.be.rejectedWith(noProjectId); + 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); + }); + + 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.' + ); + }); + + 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.' + ); + }); + }); + + 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 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 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 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.' - ); + it('should resolve with the GraphQL response on success', async () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + 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', + url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, + headers: EXPECTED_HEADERS, + data: { query: 'query' } + }); }); - 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 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)); + 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' } + }); + }); + }); + + describe('executeQuery', () => { + const unauthenticatedOptions: OperationOptions = { impersonate: { unauthenticated: true } }; + const authenticatedOptions: OperationOptions = { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; + + 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 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 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 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); }); }); - 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' + describe('should resolve with the GraphQL response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + it('for an unauthenticated request', async () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + const resp = await apiClient.executeQuery( + 'unauthenticated query', + 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', + 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', + extensions: unauthenticatedOptions + } + }); + }); + + it('for an authenticated request', async () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + const resp = await apiClient.executeQuery( + 'authenticated query', + 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', + 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', + extensions: { impersonate: authenticatedOptions.impersonate } + } + }); + }); + }); + + 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)); + await apiClient.executeQuery( + 'unauthenticated query', + 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}:impersonateQuery`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { + operationName: 'unauthenticated query', + extensions: unauthenticatedOptions + } }); }); + }); + + const unauthenticatedOptions: OperationOptions = + { impersonate: { unauthenticated: true } }; + const authenticatedOptions: OperationOptions = + { impersonate: { authClaims: { sub: 'authenticated-UUID' } } }; + + describe('executeMutation', () => { + it('should reject when no operationName is provided', () => { + 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('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('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.`); + }); 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', {}) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -169,7 +431,7 @@ describe('DataConnectApiClient', () => { .stub(HttpClient.prototype, 'send') .rejects(utils.errorFrom({}, 404)); const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); - return apiClient.executeGraphql('query', {}) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -179,7 +441,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.executeGraphql('query', {}) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); @@ -188,11 +450,11 @@ describe('DataConnectApiClient', () => { sandbox .stub(HttpClient.prototype, 'send') .rejects(expected); - return apiClient.executeGraphql('query', {}) + return apiClient.executeMutation('unauthenticated mutation', undefined, unauthenticatedOptions) .should.eventually.be.rejected.and.deep.include(expected); }); - it('should resolve with the GraphQL response on success', () => { + describe('should resolve with the GraphQL response on success', () => { interface UsersResponse { users: [ user: { @@ -202,38 +464,63 @@ 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', - url: `https://firebasedataconnect.googleapis.com/v1/projects/test-project/locations/${connectorConfig.location}/services/${connectorConfig.serviceId}:executeGraphql`, - headers: EXPECTED_HEADERS, - data: { query: 'query' } - }); + + it('for an unauthenticated request', async () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + 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', + 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', + extensions: unauthenticatedOptions + } + }); + }); + + it('for an authenticated request', async () => { + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + 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', + 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', + 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.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.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 + } + }); }); it('should use gen headers if set on success', () => { interface UsersResponse { @@ -276,6 +563,7 @@ describe('DataConnectApiClient CRUD helpers', () => { const connectorConfig: ConnectorConfig = { location: 'us-west1', serviceId: 'my-crud-service', + connector: 'my-crud-connector', }; const mockOptions = { @@ -349,9 +637,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)); @@ -362,9 +650,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)); @@ -374,12 +662,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)); @@ -435,7 +723,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)); }); @@ -448,18 +736,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); @@ -523,12 +811,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)); @@ -594,18 +882,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); @@ -637,4 +925,4 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`); }); }); -}); +}); \ No newline at end of file