diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index 561850b3749..fc9b05fa272 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -229,6 +229,12 @@ ts_project( "src/cody/management/CodyManagementPage.tsx", "src/cody/management/SubscriptionStats.tsx", "src/cody/management/UseCodyInEditorSection.tsx", + "src/cody/management/api/client.ts", + "src/cody/management/api/components/CodyProApiClient.ts", + "src/cody/management/api/hooks/useApiClient.tsx", + "src/cody/management/api/stripeCheckout.ts", + "src/cody/management/api/teamSubscriptions.ts", + "src/cody/management/api/types.ts", "src/cody/management/subscription/new/CodyProCheckoutForm.tsx", "src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx", "src/cody/onboarding/CodyOnboarding.tsx", @@ -1888,6 +1894,7 @@ ts_project( "src/backend/persistenceMapper.test.ts", "src/codeintel/ReferencesPanel.mocks.ts", "src/codeintel/ReferencesPanel.test.tsx", + "src/cody/management/api/hooks/useApiClient.test.tsx", "src/cody/useCodyIgnore.test.ts", "src/components/ErrorBoundary.test.tsx", "src/components/FilteredConnection/FilteredConnection.test.tsx", diff --git a/client/web/src/cody/management/api/README.md b/client/web/src/cody/management/api/README.md new file mode 100644 index 00000000000..f1c773aeab6 --- /dev/null +++ b/client/web/src/cody/management/api/README.md @@ -0,0 +1,33 @@ +# Cody Pro Client API Library + +This module contains the Cody Pro REST API client library and associated types. + +These are specific to the API used for managing Cody Pro subscriptions, and its associated +microservice backend. For more information, see the `sourcegraph/self-serve-cody` repo which +implements the server-side of this API. + +For compatibility, the data types MUST match the Golang definitions in the `internal/api/types` +package. Care must also be taken on the Golang side to avoid breaking changes, such as not +renaming the JSON serialization of data types, only adding new fields, etc. + +For any other Sourcegraph backend interactions from the frontend, that should be using the +GraphQL API. + +## Usage + +The API client is exposed as two React hooks: `useApiClient` and `useApiCaller`. + +The `Client` provides a strongly-typed definition of the REST API exposed as a synchronous methods. +However, they just return `Call` objects which merely _describe_ the API call to be made. +A separate `Caller` is what actually performs the operation. + +⚠️ It's super important to wrap the `Call` object's creation in `useMemo`. Otherwise, any time +the calling React comment gets repainted, the object reference passed to `useApiCaller` will change, +leading to additional HTTP requests being made unintentionally! + +```ts +// Make the API call to create the Stripe Checkout session. +// Make the API call to create the Stripe Checkout session. +const call = useMemo(() => Client.createStripeCheckoutSession(req), [req.customerEmail, req.showPromoCodeField]) +const { loading, error, data } = useApiCaller(call) +``` diff --git a/client/web/src/cody/management/api/client.ts b/client/web/src/cody/management/api/client.ts new file mode 100644 index 00000000000..a23f23edff4 --- /dev/null +++ b/client/web/src/cody/management/api/client.ts @@ -0,0 +1,103 @@ +import type * as types from './types' + +// Client provides the metadata for the methods exposed from the Cody Pro API client. +// +// This doesn't _do_ anything, it just returns the metadata for what needs to be done. +// It is used in conjunction with a Caller implementation for actually fetching data. +// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace +export module Client { + // Subscriptions + + export function getCurrentSubscription(): Call { + return { method: 'GET', urlSuffix: '/team/current/subscription' } + } + + export function getCurrentSubscriptionSummary(): Call { + return { method: 'GET', urlSuffix: '/team/current/subscription/summary' } + } + + export function updateCurrentSubscription(requestBody: types.UpdateSubscriptionRequest): Call { + return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody } + } + + export function getCurrentSubscriptionInvoices(): Call { + return { method: 'GET', urlSuffix: '/team/current/subscription/invoices' } + } + + export function reactivateCurrentSubscription( + requestBody: types.ReactivateSubscriptionRequest + ): Call { + return { method: 'POST', urlSuffix: '/team/current/subscription/reactivate', requestBody } + } + + // Stripe Checkout + + export function createStripeCheckoutSession( + requestBody: types.CreateCheckoutSessionRequest + ): Call { + return { method: 'POST', urlSuffix: '/checkout/session', requestBody } + } +} + +// Call is the bundle of data necessary for making an API request. +// This is a sort of "meta request" in the same veign as the `gql` +// template tag, see: https://github.com/apollographql/graphql-tag +export interface Call { + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' + urlSuffix: string + requestBody?: any + + // Unused. This will never be set, it is only to + // pass along the expected response type. + responseBody?: Resp +} + +// Caller is a wrapper around an HTTP client. An implementation of this interface +// will be responsible for making API calls to the backend. +export interface Caller { + // call performs the described HTTP request, returning the response body deserialized from + // JSON as `data`, and the full HTTP response object as `response`. + call(call: Call): Promise<{ data?: Data; response: Response }> +} + +// CodyProApiCaller is an implementation of the Caller interface which issues API calls to +// the current Sourcegraph instance's SSC proxy API endpoint. +export class CodyProApiCaller implements Caller { + // e.g. "https://sourcegraph.com" + private origin: string + + constructor() { + this.origin = window.location.origin + } + + public async call(call: Call): Promise<{ data?: Data; response: Response }> { + let bodyJson: string | undefined + if (call.requestBody) { + bodyJson = JSON.stringify(call.requestBody) + } + + const fetchResponse = await fetch(`${this.origin}/.api/ssc/proxy${call.urlSuffix}`, { + // Pass along the "sgs" session cookie to identify the caller. + credentials: 'same-origin', + method: call.method, + body: bodyJson, + }) + + if (fetchResponse.status >= 200 && fetchResponse.status <= 299) { + const rawBody = await fetchResponse.text() + const typedResp = JSON.parse(rawBody) as Data + return { + data: typedResp, + response: fetchResponse, + } + } + + // Otherwise just return the raw response. We rely on the caller + // to confirm that the Response object indicates success, and to + // handle any 4xx or 5xx status codes. + return { + data: undefined, + response: fetchResponse, + } + } +} diff --git a/client/web/src/cody/management/api/components/CodyProApiClient.ts b/client/web/src/cody/management/api/components/CodyProApiClient.ts new file mode 100644 index 00000000000..0e65668abc3 --- /dev/null +++ b/client/web/src/cody/management/api/components/CodyProApiClient.ts @@ -0,0 +1,21 @@ +import { createContext } from 'react' + +import { Caller, CodyProApiCaller } from '../client' + +export interface CodyProApiClient { + caller: Caller +} + +// Helper for returning a default value, for the API client contacting the local +// Sourcegraph backend for making API calls. +export function defaultCodyProApiClientContext(): CodyProApiClient { + return { + caller: new CodyProApiCaller(), + } +} + +// Context for supplying a Cody Pro API client to a React component tree. +// +// The default value will be a functional API client that makes HTTP requests +// to the current Sourcegraph instance's backend. +export const CodyProApiClientContext = createContext(defaultCodyProApiClientContext()) diff --git a/client/web/src/cody/management/api/hooks/useApiClient.test.tsx b/client/web/src/cody/management/api/hooks/useApiClient.test.tsx new file mode 100644 index 00000000000..37df0368301 --- /dev/null +++ b/client/web/src/cody/management/api/hooks/useApiClient.test.tsx @@ -0,0 +1,138 @@ +import React from 'react' + +import { renderHook } from '@testing-library/react-hooks' +import { describe, expect, it } from 'vitest' + +import { Call, Caller } from '../client' +import { CodyProApiClientContext } from '../components/CodyProApiClient' + +import { useApiCaller } from './useApiClient' + +// FakeCaller is a testing fake for the Caller interface, for simulating +// making API calls. Only supports one call being made at a time, otherwise +// will fail. +// +// It's hard to do async hook testing correctly. This might be helpful: +// https://react-hooks-testing-library.com/usage/advanced-hooks#async +class FakeCaller implements Caller { + private callInFlight = false + private resolveLastCallFn: any | undefined = undefined + private rejectLastCallFn: any | undefined = undefined + + public call(_: Call): Promise<{ data?: Data; response: Response }> { + if (this.callInFlight) { + throw new Error('There is already a call in-flight. You must call `reset()`') + } + + return new Promise<{ data?: Data; response: Response }>((resolve, reject) => { + this.callInFlight = true + this.resolveLastCallFn = resolve + this.rejectLastCallFn = reject + + // We leave the promise in this running state, + // requiring the testcase to call resolveLastCallWith. + }) + } + + public isCallInFlight(): boolean { + return this.callInFlight + } + + public resolveLastCallWith(result: { data?: Data; response: Response }) { + if (!this.resolveLastCallFn) { + throw new Error('Cannot resolve. There is no call in-flight.') + } + this.resolveLastCallFn(result) + this.reset() + } + + public rejectLastCallWith(reason: any) { + if (!this.rejectLastCallFn) { + throw new Error('Cannot reject. There is no call in-flight.') + } + this.rejectLastCallFn(reason) + this.reset() + } + + public reset() { + if (!this.callInFlight) { + throw new Error('Cannot reset. There is no call in-flight') + } + this.callInFlight = false + this.resolveLastCallFn = undefined + this.rejectLastCallFn = undefined + } +} + +describe('useApiCaller()', () => { + const mockCaller = new FakeCaller() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + // responseStub is a stubbed out Response object. + const responseStub: Response = { + status: 200, + } as any + + it('works', async () => { + const call: Call = { + method: 'GET', + urlSuffix: '/test', + } + + // Verify the initial state is loading. + const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper }) + { + const { loading, error, data } = result.current + expect(loading).toBe(true) + expect(data).toBeUndefined() + expect(error).toBeUndefined() + } + + // Resolve the promise that was returned by the API call made by + // the useApiCaller hook. + expect(mockCaller.isCallInFlight()).toBe(true) + mockCaller.resolveLastCallWith({ data: 'some value', response: responseStub }) + expect(mockCaller.isCallInFlight()).toBe(false) + + // Now we need to kick the React runtime to pick up on the change. + await waitForNextUpdate() + + // Verify the updated state has the result from the caller. + { + const { loading, error, data } = result.current + expect(loading).toBe(false) + expect(data).toBe('some value') + expect(error).toBeUndefined() + } + }) + + it('handles runtime errors', async () => { + const call: Call = { + method: 'GET', + urlSuffix: '/test', + } + + // Verify the initial state is loading. + const { result, waitForNextUpdate } = renderHook(() => useApiCaller(call), { wrapper }) + { + const { loading, error, data } = result.current + expect(loading).toBe(true) + expect(data).toBeUndefined() + expect(error).toBeUndefined() + } + + mockCaller.rejectLastCallWith(new Error('Random Network Error')) + await waitForNextUpdate() + + // Verify the error field is set + { + const { loading, error, data } = result.current + expect(loading).toBe(false) + expect(data).toBeUndefined() + expect(error).toBeTruthy() + expect(error?.message).toBe('Random Network Error') + } + }) +}) diff --git a/client/web/src/cody/management/api/hooks/useApiClient.tsx b/client/web/src/cody/management/api/hooks/useApiClient.tsx new file mode 100644 index 00000000000..4c3a8400a20 --- /dev/null +++ b/client/web/src/cody/management/api/hooks/useApiClient.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState, useContext } from 'react' + +import { Call } from '../client' +import { CodyProApiClientContext } from '../components/CodyProApiClient' + +export interface ReactFriendlyApiResponse { + loading: boolean + error?: Error + data?: T + response?: Response +} + +// useApiCaller will issue a REST API call to the backend, returning the +// loading status and the response JSON object and/or error object as React +// state. +// +// IMPORTANT: In order to avoid the same API request being made multiple times, +// you MUST ensure that the provided call is the same between repains of the +// calling React component. i.e. you pretty much always need to create it via +// `useMemo()`. +export function useApiCaller(call: Call): ReactFriendlyApiResponse { + const { caller } = useContext(CodyProApiClientContext) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(undefined) + const [data, setData] = useState(undefined) + const [response, setResponse] = useState(undefined) + + useEffect(() => { + // `ignore` tracks if we should discard any results, because of any underlying race condition + // in the sequence of API calls. We return a handle to this in the function callback, which + // the React runtime may invoke (setting ignore = true) outside of our view. + // https://react.dev/reference/react/useEffect#fetching-data-with-effects + // https://maxrozen.com/race-conditions-fetching-data-react-with-useeffect + let ignore = false + + ;(async () => { + try { + const callerResponse = await caller.call(call) + + if (ignore) { + return + } + + // If we received a 200 response, all is well. We can just return + // the unmarshalled JSON response object as-is. + setLoading(false) + if (callerResponse.response.status >= 200 && callerResponse.response.status <= 299) { + setData(callerResponse.data) + setError(undefined) + setResponse(callerResponse.response) + } else { + // For a 4xx or 5xx response this is where we provide any standardized logic for + // error handling. For example: + // + // - On a 401 response, we need to force-logout the user so they can refresh their + // SAMS access token. + // - On a 500 response, perhaps replace the current UI with a full-page error. e.g. + // http://github.com/500 or http://github.com/501 + setData(undefined) + setError(new Error(`unexpected status code: ${callerResponse.response.status}`)) + setResponse(callerResponse.response) + + // Provide a clearer message. A 401 typically comes from the user's SAMS credentials + // having expired on the backend. + if (callerResponse.response.status === 401) { + setError(new Error('Please log out and log back in.')) + } + } + } catch (error) { + if (ignore) { + return + } + setData(undefined) + setError(error) + setResponse(undefined) + setLoading(false) + } + })() + + return () => { + ignore = true + } + }, [call, caller]) + + return { loading, error, data, response } +} diff --git a/client/web/src/cody/management/api/stripeCheckout.ts b/client/web/src/cody/management/api/stripeCheckout.ts new file mode 100644 index 00000000000..5c7e61f845a --- /dev/null +++ b/client/web/src/cody/management/api/stripeCheckout.ts @@ -0,0 +1,25 @@ +import { BillingInterval } from './teamSubscriptions' + +export interface CreateCheckoutSessionRequest { + interval: BillingInterval + seats: number + + customerEmail?: string + showPromoCodeField: boolean + + returnUrl?: string +} + +export interface CreateCheckoutSessionResponse { + clientSecret: string +} + +export interface GetCheckoutSessionResponse { + // The only valid state is "complete". Any other string implies that the + // checkout was not successful, and no new Cody Pro team was registered. + status: string + + // The only valid state is "paid" (IFF status is also "complete"). + // Anything else means the team/subscription was not registered. + paymentStatus: string +} diff --git a/client/web/src/cody/management/api/teamSubscriptions.ts b/client/web/src/cody/management/api/teamSubscriptions.ts new file mode 100644 index 00000000000..25c4cc1e876 --- /dev/null +++ b/client/web/src/cody/management/api/teamSubscriptions.ts @@ -0,0 +1,118 @@ +export type TeamRole = 'none' | 'member' | 'admin' + +// BillingInterval is the subscription's billing cycle. 'daily' is only +// available in the dev environment. +export type BillingInterval = 'daily' | 'monthly' | 'yearly' + +// UsdCents is used to wrap any situation involving money, which +// should always be an integer referring to USD cents. e.g. +// 105 corresponds to $1.05. +export type UsdCents = number + +export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'other' + +export type SubscriptionStatus = 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trailing' | 'other' + +export interface Address { + line1: string + line2: string + city: string + state: string + postalCode: string + country: string +} + +export interface PaymentMethod { + expMonth: number + expYear: number + last4: string +} + +export interface PreviewResult { + dueNow: UsdCents + newPrice: UsdCents + dueDate: Date +} + +export interface DiscountInfo { + description: string + expiresAt?: Date +} + +export interface Invoice { + date: Date + + amountDue: UsdCents + amountPaid: UsdCents + status: InvoiceStatus + + periodStart: Date + periodEnd: Date + + hostedInvoiceUrl?: string + pdfUrl?: string +} + +export interface SubscriptionSummary { + teamId: string + + userRole: TeamRole + teamCurrentMembers: number + teamMaxMembers: number + + subscriptionStatus: SubscriptionStatus + cancelAtPeriodEnd: boolean +} + +export interface Subscription { + createdAt: Date + endedAt?: Date + + primaryEmail: string + name: string + address: Address + + subscriptionStatus: SubscriptionStatus + cancelAtPeriodEnd: boolean + billingInterval: BillingInterval + + discountInfo?: DiscountInfo + + currentPeriodStart: Date + currentPeriodEnd: Date + + paymentMethod?: PaymentMethod + nextInvoice?: PreviewResult + + maxSeats: number +} + +export interface CustomerUpdateOptions { + newName?: string + newEmail?: string + newAddress?: Address + newCreditCardToken?: string +} + +// Is a discriminated union. Exactly one field should be set at a time. +export interface SubscriptionUpdateOptions { + newSeatCount?: number + newBillingInterval?: BillingInterval + newCancelAtPeriodEnd?: boolean +} + +export interface ReactivateSubscriptionRequest { + seatLimit: number + billingInterval: BillingInterval + creditCardToken?: string +} + +export interface UpdateSubscriptionRequest { + customerUpdate?: CustomerUpdateOptions + subscriptionUpdate?: SubscriptionUpdateOptions +} + +export interface GetSubscriptionInvoicesResponse { + invoices: Invoice[] + continuationToken?: string +} diff --git a/client/web/src/cody/management/api/types.ts b/client/web/src/cody/management/api/types.ts new file mode 100644 index 00000000000..0a8dca5ba9b --- /dev/null +++ b/client/web/src/cody/management/api/types.ts @@ -0,0 +1,4 @@ +// Export all of the API types, so consumers we can organize the type definitions +// into smaller files, without consumers needing to care about that organization. +export * from './teamSubscriptions' +export * from './stripeCheckout' diff --git a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx index 94ca419fb53..89ff953ede1 100644 --- a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx +++ b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx @@ -1,12 +1,14 @@ -import React, { useState, useEffect } from 'react' +import React, { useMemo } from 'react' import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js' import type { Stripe } from '@stripe/stripe-js' import { useSearchParams } from 'react-router-dom' -import { H3, Text } from '@sourcegraph/wildcard' +import { H3, LoadingSpinner, Text } from '@sourcegraph/wildcard' -import { requestSSC } from '../../../util' +import { Client } from '../../api/client' +import { useApiCaller } from '../../api/hooks/useApiClient' +import { CreateCheckoutSessionRequest } from '../../api/types' /** * CodyProCheckoutForm is essentially an iframe that the Stripe Elements library will @@ -16,88 +18,56 @@ export const CodyProCheckoutForm: React.FunctionComponent<{ stripePromise: Promise customerEmail: string | undefined }> = ({ stripePromise, customerEmail }) => { - const [clientSecret, setClientSecret] = useState('') - const [errorDetails, setErrorDetails] = useState('') - const [urlSearchParams] = useSearchParams() - - // Optionally support the "showCouponCodeAtCheckout" URL query parameter, which is present + // Optionally support the "showCouponCodeAtCheckout" URL query parameter, which, if present, // will display a "promotional code" element in the Stripe Checkout UI. + const [urlSearchParams] = useSearchParams() const showPromoCodeField = urlSearchParams.get('showCouponCodeAtCheckout') !== null - // Issue an API call to the backend asking it to create a new checkout session. - // This will update clientSecret/errorDetails asynchronously when the request completes. - useEffect(() => { - // useEffect will not accept a Promise, so we call - // createCheckoutSession and let it run async. - // (And not `await createCheckoutSession` or `return createCheckoutSession`.) - void createCheckoutSession('monthly', showPromoCodeField, customerEmail, setClientSecret, setErrorDetails) - }, [customerEmail, showPromoCodeField, setClientSecret, setErrorDetails]) - - const options /* unexported EmbeddedCheckoutProviderProps.options */ = { - clientSecret, - } - return ( -
- {errorDetails && ( - <> -

Awe snap!

- There was an error creating the checkout session: {errorDetails} - - )} - - {clientSecret && ( - - - - )} -
- ) -} - -// createSessionResponse is the API response returned from the SSC backend when -// we ask it to create a new Stripe Checkout Session. -interface createSessionResponse { - clientSecret: string -} - -// createCheckoutSession initiates the API request to the backend to create a Stripe Checkout session. -// Upon completion, the `setClientSecret` or `setErrorDetails` will be called to report the result. -async function createCheckoutSession( - billingInterval: string, - showPromoCodeField: boolean, - customerEmail: string | undefined, - setClientSecret: (arg: string) => void, - setErrorDetails: (arg: string) => void -): Promise { - // e.g. "https://sourcegraph.com" - const origin = window.location.origin - - try { - const response = await requestSSC('/checkout/session', 'POST', { - interval: billingInterval, + // Make the API call to create the Stripe Checkout session. + const call = useMemo(() => { + const req: CreateCheckoutSessionRequest = { + interval: 'monthly', seats: 1, customerEmail, showPromoCodeField, // URL the user is redirected to when the checkout process is complete. // + // CHECKOUT_SESSION_ID will be replaced by Stripe with the correct value, + // when the user finishes the Stripe-hosted checkout form. + // // BUG: Due to the race conditions between Stripe, the SSC backend, // and Sourcegraph.com, immediately loading the Dashboard page isn't // going to show the right data reliably. We will need to instead show - // some interstitial or welcome prompt, to give various things to sync. + // some prompt, to give the backends an opportunity to sync. returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`, - }) - - const responseBody = await response.text() - if (response.status >= 200 && response.status <= 299) { - const typedResp = JSON.parse(responseBody) as createSessionResponse - setClientSecret(typedResp.clientSecret) - } else { - // Pass any 4xx or 5xx directly to the user. We expect the - // server to have properly redacted any sensitive information. - setErrorDetails(responseBody) } - } catch (error) { - setErrorDetails(`unhandled exception: ${JSON.stringify(error)}`) + return Client.createStripeCheckoutSession(req) + }, [customerEmail, showPromoCodeField]) + const { loading, error, data } = useApiCaller(call) + + // Show a spinner while we wait for the Checkout session to be created. + if (loading) { + return } + + // Error page if we aren't able to show the Checkout session. + if (error) { + return ( +
+

Awe snap!

+ There was an error creating the checkout session: {error.message} +
+ ) + } + + return ( +
+ {data && data.clientSecret && ( + + + + )} +
+ ) } diff --git a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx index 9250c9e58ac..ae318d2d0b3 100644 --- a/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx +++ b/client/web/src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx @@ -24,6 +24,7 @@ import { } from '../../../../graphql-operations' import { CodyProIcon } from '../../../components/CodyIcon' import { USER_CODY_PLAN } from '../../../subscription/queries' +import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient' import { CodyProCheckoutForm } from './CodyProCheckoutForm' @@ -72,12 +73,14 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent - - - + + + + + ) diff --git a/dev/gqltest/README.md b/dev/gqltest/README.md index ac2224b7c4f..a7de191b5a1 100644 --- a/dev/gqltest/README.md +++ b/dev/gqltest/README.md @@ -17,7 +17,7 @@ instead. Steps: -1. Request ["CI Secrets Read Access"](https://app.entitle.io/request?data=eyJkdXJhdGlvbiI6Ijg2NDAwIiwianVzdGlmaWNhdGlvbiI6IlJ1bm5pbmcgYmFja2VuZCBpbnRlZ3JhdGlvbiB0ZXN0cyBsb2NhbGx5IiwiYnVuZGxlSWRzIjpbIjA2ZmRkZTIwLWY1Y2MtNDdlMS1iZGYxLWZkZTUwYjhhNDE3MCJdfQ%3D%3D) in Entitle +1. Request "CI Secrets Read Access" in Entitle 2. Run `sg test bazel-backend-integration` ## How to add new tests