Skip to content

feat(frontend): add operator-contextual dropdowns for tenant selection #3289

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions localenv/mock-account-servicing-entity/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions packages/backend/src/graphql/generated/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/backend/src/graphql/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 55 additions & 1 deletion packages/backend/src/graphql/resolvers/asset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import assert from 'assert'
import { v4 as uuid } from 'uuid'

import { getPageTests } from './page.test'
import { createTestApp, TestContainer } from '../../tests/app'
import {
createApolloClient,
createTestApp,
TestContainer
} from '../../tests/app'
import { IocContract } from '@adonisjs/fold'
import { AppServices } from '../../app'
import { initIocContainer } from '../..'
Expand Down Expand Up @@ -32,6 +36,7 @@ import { isFeeError } from '../../fee/errors'
import { createFee } from '../../tests/fee'
import { createAsset } from '../../tests/asset'
import { GraphQLErrorCode } from '../errors'
import { createTenant } from '../../tests/tenant'

describe('Asset Resolvers', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -212,6 +217,55 @@ describe('Asset Resolvers', (): void => {
)
}
})

test('bad input data when not allowed to perform cross tenant create', async (): Promise<void> => {
const otherTenant = await createTenant(deps)
const badInputData = {
...randomAsset(),
tenantId: uuid()
}

const tenantedApolloClient = await createApolloClient(
appContainer.container,
appContainer.app,
otherTenant.id
)
try {
expect.assertions(2)
await tenantedApolloClient
.mutate({
mutation: gql`
mutation CreateAsset($input: CreateAssetInput!) {
createAsset(input: $input) {
asset {
id
}
}
}
`,
variables: {
input: badInputData
}
})
.then((query): AssetMutationResponse => {
if (query.data) {
return query.data.createAsset
} else {
throw new Error('Data was empty')
}
})
} catch (error) {
expect(error).toBeInstanceOf(ApolloError)
expect((error as ApolloError).graphQLErrors).toContainEqual(
expect.objectContaining({
message: 'Assignment to the specified tenant is not permitted',
extensions: expect.objectContaining({
code: GraphQLErrorCode.BadUserInput
})
})
)
}
})
})

describe('Asset Queries', (): void => {
Expand Down
28 changes: 21 additions & 7 deletions packages/backend/src/graphql/resolvers/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '../generated/graphql'
import { Asset } from '../../asset/model'
import { errorToCode, errorToMessage, isAssetError } from '../../asset/errors'
import { TenantedApolloContext } from '../../app'
import { ForTenantIdContext, TenantedApolloContext } from '../../app'
import { getPageInfo } from '../../shared/pagination'
import { Pagination, SortOrder } from '../../shared/baseModel'
import { feeToGraphql } from './fee'
Expand All @@ -23,14 +23,14 @@ export const getAssets: QueryResolvers<TenantedApolloContext>['assets'] =
const assets = await assetService.getPage({
pagination,
sortOrder: order,
tenantId: ctx.tenant.id
tenantId: ctx.isOperator ? undefined : ctx.tenant.id
})
const pageInfo = await getPageInfo({
getPage: (pagination: Pagination, sortOrder?: SortOrder) =>
assetService.getPage({
pagination,
sortOrder,
tenantId: ctx.tenant.id
tenantId: ctx.isOperator ? undefined : ctx.tenant.id
}),
page: assets,
sortOrder: order
Expand All @@ -50,7 +50,10 @@ export const getAsset: QueryResolvers<TenantedApolloContext>['asset'] = async (
ctx
): Promise<ResolversTypes['Asset']> => {
const assetService = await ctx.container.use('assetService')
const asset = await assetService.get(args.id, ctx.tenant.id)
const asset = await assetService.get(
args.id,
ctx.isOperator ? undefined : ctx.tenant.id
)
if (!asset) {
throw new GraphQLError('Asset not found', {
extensions: {
Expand All @@ -72,16 +75,26 @@ export const getAssetByCodeAndScale: QueryResolvers<TenantedApolloContext>['asse
return asset ? assetToGraphql(asset) : null
}

export const createAsset: MutationResolvers<TenantedApolloContext>['createAsset'] =
export const createAsset: MutationResolvers<ForTenantIdContext>['createAsset'] =
async (
parent,
args,
ctx
): Promise<ResolversTypes['AssetMutationResponse']> => {
const tenantId = ctx.forTenantId
if (!tenantId)
throw new GraphQLError(
`Assignment to the specified tenant is not permitted`,
{
extensions: {
code: GraphQLErrorCode.BadUserInput
}
}
)
const assetService = await ctx.container.use('assetService')
const assetOrError = await assetService.create({
...args.input,
tenantId: ctx.tenant.id
tenantId
})
if (isAssetError(assetOrError)) {
throw new GraphQLError(errorToMessage[assetOrError], {
Expand Down Expand Up @@ -203,5 +216,6 @@ export const assetToGraphql = (asset: Asset): SchemaAsset => ({
scale: asset.scale,
withdrawalThreshold: asset.withdrawalThreshold,
liquidityThreshold: asset.liquidityThreshold,
createdAt: new Date(+asset.createdAt).toISOString()
createdAt: new Date(+asset.createdAt).toISOString(),
tenantId: asset.tenantId
})
3 changes: 3 additions & 0 deletions packages/backend/src/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ input CreateAssetInput {
liquidityThreshold: UInt64
"Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)."
idempotencyKey: String
"Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature."
tenantId: ID
}

input UpdateAssetInput {
Expand Down Expand Up @@ -673,6 +675,7 @@ type Asset implements Model {
): FeesConnection
"The date and time when the asset was created."
createdAt: String!
tenantId: ID!
}

enum SortOrder {
Expand Down
71 changes: 40 additions & 31 deletions packages/backend/src/tests/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,13 @@ export interface TestContainer {
container: IocContract<AppServices>
}

export const createTestApp = async (
container: IocContract<AppServices>
): Promise<TestContainer> => {
const config = await container.use('config')
config.adminPort = 0
config.openPaymentsPort = 0
config.connectorPort = 0
config.autoPeeringServerPort = 0
config.openPaymentsUrl = 'https://op.example'
config.walletAddressUrl = 'https://wallet.example/.well-known/pay'
export const createApolloClient = async (
container: IocContract<AppServices>,
app: App,
tenantId?: string
): Promise<ApolloClient<NormalizedCacheObject>> => {
const logger = await container.use('logger')

const app = new App(container)
await start(container, app)

const nock = (global as unknown as { nock: typeof import('nock') }).nock

// Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server
nock(config.openPaymentsUrl)
.get(/.*/)
.matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./)
.reply(200, function (path) {
return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, {
headers: this.req.headers
}).then((res) => res.data)
})
.persist()

const knex = await container.use('knex')

const config = await container.use('config')
const httpLink = createHttpLink({
uri: `http://localhost:${app.getAdminPort()}/graphql`,
fetch
Expand All @@ -80,14 +57,14 @@ export const createTestApp = async (
return {
headers: {
...headers,
'tenant-id': config.operatorTenantId
'tenant-id': tenantId || config.operatorTenantId
}
}
})

const link = ApolloLink.from([errorLink, authLink, httpLink])

const client = new ApolloClient({
return new ApolloClient({
cache: new InMemoryCache({}),
link: link,
defaultOptions: {
Expand All @@ -102,6 +79,38 @@ export const createTestApp = async (
}
}
})
}

export const createTestApp = async (
container: IocContract<AppServices>
): Promise<TestContainer> => {
const config = await container.use('config')
config.adminPort = 0
config.openPaymentsPort = 0
config.connectorPort = 0
config.autoPeeringServerPort = 0
config.openPaymentsUrl = 'https://op.example'
config.walletAddressUrl = 'https://wallet.example/.well-known/pay'

const app = new App(container)
await start(container, app)

const nock = (global as unknown as { nock: typeof import('nock') }).nock

// Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server
nock(config.openPaymentsUrl)
.get(/.*/)
.matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./)
.reply(200, function (path) {
return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, {
headers: this.req.headers
}).then((res) => res.data)
})
.persist()

const knex = await container.use('knex')

const client = await createApolloClient(container, app)

return {
app,
Expand Down
Loading
Loading