Skip to content
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
1 change: 1 addition & 0 deletions localenv/cloud-ten-wallet/seed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,4 @@ tenants:
idpSecret: 'ue3ixgIiWLIlWOd4w5KO78scYpFH+vHuCJ33lnjgzEg='
walletAddressPrefix: 'https://cloud-nine-wallet-backend/cloud-ten'
webhookUrl: 'http://cloud-ten-wallet/webhooks'
id: 'bc293b79-8609-47bd-b914-6438b470aff8'
2 changes: 2 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.

12 changes: 12 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.

2 changes: 2 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.

73 changes: 73 additions & 0 deletions packages/backend/src/graphql/resolvers/tenant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { truncateTables } from '../../tests/tableManager'
import { ApolloClient } from '@apollo/client'
import { GraphQLErrorCode } from '../errors'
import { Tenant as TenantModel } from '../../tenants/model'
import { v4 } from 'uuid'
import { errorToMessage, TenantError } from '../../tenants/errors'

describe('Tenant Resolvers', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -256,6 +258,77 @@ describe('Tenant Resolvers', (): void => {
})
})

test('can create a tenant with specific id', async (): Promise<void> => {
const inputId = v4()
const input = { ...generateTenantInput(), id: inputId }

const mutation = await appContainer.apolloClient
.mutate({
mutation: gql`
mutation CreateTenant($input: CreateTenantInput!) {
createTenant(input: $input) {
tenant {
id
email
apiSecret
idpConsentUrl
idpSecret
publicName
}
}
}
`,
variables: {
input
}
})
.then((query): TenantMutationResponse => query.data?.createTenant)

expect(mutation.tenant).toEqual({
...input,
__typename: 'Tenant'
})
})

test('cannot create tenant with invalid uuid specified', async (): Promise<void> => {
expect.assertions(2)
try {
const input = { ...generateTenantInput(), id: 'invalid-id-format' }

await appContainer.apolloClient
.mutate({
mutation: gql`
mutation CreateTenant($input: CreateTenantInput!) {
createTenant(input: $input) {
tenant {
id
email
apiSecret
idpConsentUrl
idpSecret
publicName
}
}
}
`,
variables: {
input
}
})
.then((query): TenantMutationResponse => query.data?.createTenant)
} catch (error) {
expect(error).toBeInstanceOf(ApolloError)
expect((error as ApolloError).graphQLErrors).toContainEqual(
expect.objectContaining({
message: errorToMessage[TenantError.InvalidTenantId],
extensions: expect.objectContaining({
code: GraphQLErrorCode.BadUserInput
})
})
)
}
})

test('cannot create tenant as non-operator', async (): Promise<void> => {
const input = generateTenantInput()
const tenant = await createTenant(deps)
Expand Down
12 changes: 10 additions & 2 deletions packages/backend/src/graphql/resolvers/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Tenant } from '../../tenants/model'
import { Pagination, SortOrder } from '../../shared/baseModel'
import { getPageInfo } from '../../shared/pagination'
import { tenantSettingsToGraphql } from './tenant_settings'
import { errorToMessage, isTenantError } from '../../tenants/errors'

export const whoami: QueryResolvers<TenantedApolloContext>['whoami'] = async (
parent,
Expand Down Expand Up @@ -102,9 +103,16 @@ export const createTenant: MutationResolvers<TenantedApolloContext>['createTenan
}

const tenantService = await ctx.container.use('tenantService')
const tenant = await tenantService.create(args.input)
const tenantOrError = await tenantService.create(args.input)
if (isTenantError(tenantOrError)) {
throw new GraphQLError(errorToMessage[tenantOrError], {
extensions: {
code: GraphQLErrorCode.BadUserInput
}
})
}

return { tenant: tenantToGraphQl(tenant) }
return { tenant: tenantToGraphQl(tenantOrError) }
}

export const updateTenant: MutationResolvers<TenantedApolloContext>['updateTenant'] =
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,8 @@ type TenantSetting {
}

input CreateTenantInput {
"Unique identifier of the tenant. Must be compliant with uuid v4. Will be generated automatically if not provided."
id: ID
"Contact email of the tenant owner."
email: String
"Secret used to secure requests made for this tenant."
Expand Down
6 changes: 4 additions & 2 deletions packages/backend/src/tenants/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum TenantError {
TenantNotFound = 'TenantNotFound'
TenantNotFound = 'TenantNotFound',
InvalidTenantId = 'InvalidTenantId'
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
Expand All @@ -9,5 +10,6 @@ export const isTenantError = (o: any): o is TenantError =>
export const errorToMessage: {
[key in TenantError]: string
} = {
[TenantError.TenantNotFound]: 'Tenant not found'
[TenantError.TenantNotFound]: 'Tenant not found',
[TenantError.InvalidTenantId]: 'Invalid Tenant ID'
}
29 changes: 28 additions & 1 deletion packages/backend/src/tenants/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { withConfigOverride } from '../tests/helpers'
import { TenantSetting, TenantSettingKeys } from './settings/model'
import { TenantSettingService } from './settings/service'
import { isTenantError, TenantError } from './errors'
import { v4 } from 'uuid'

describe('Tenant Service', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -128,7 +129,7 @@ describe('Tenant Service', (): void => {
.mockImplementationOnce(async () => undefined)

const tenant = await tenantService.create(createOptions)

assert(!isTenantError(tenant))
expect(tenant).toEqual(expect.objectContaining(createOptions))

expect(spy).toHaveBeenCalledWith(
Expand Down Expand Up @@ -167,6 +168,7 @@ describe('Tenant Service', (): void => {
.mockImplementationOnce(async () => undefined)

const tenant = await tenantService.create(createOptions)
assert(!isTenantError(tenant))
const tenantSetting = await TenantSetting.query()
.where('tenantId', tenant.id)
.andWhere('key', TenantSettingKeys.WALLET_ADDRESS_URL.name)
Expand All @@ -175,6 +177,26 @@ describe('Tenant Service', (): void => {
expect(tenantSetting[0].value).toEqual(walletAddressUrl)
})

test('can create tenant with a specified id', async (): Promise<void> => {
const inputId = v4()
const createOptions = {
id: inputId,
apiSecret: 'test-api-secret',
publicName: 'test tenant',
email: faker.internet.email(),
idpConsentUrl: faker.internet.url(),
idpSecret: 'test-idp-secret'
}

jest
.spyOn(authServiceClient.tenant, 'create')
.mockImplementationOnce(async () => undefined)

const tenant = await tenantService.create(createOptions)
assert(!isTenantError(tenant))
expect(tenant.id).toEqual(inputId)
})

test('tenant creation rolls back if auth tenant create fails', async (): Promise<void> => {
const createOptions = {
apiSecret: 'test-api-secret',
Expand Down Expand Up @@ -226,6 +248,7 @@ describe('Tenant Service', (): void => {
.mockImplementationOnce(async () => undefined)

const tenant = await tenantService.create(originalTenantInfo)
assert(!isTenantError(tenant))

const updatedTenantInfo = {
id: tenant.id,
Expand Down Expand Up @@ -262,6 +285,7 @@ describe('Tenant Service', (): void => {
.mockImplementationOnce(async () => undefined)

const tenant = await tenantService.create(originalTenantInfo)
assert(!isTenantError(tenant))
const updatedTenantInfo = {
id: tenant.id,
apiSecret: 'test-api-secret-two',
Expand Down Expand Up @@ -338,6 +362,7 @@ describe('Tenant Service', (): void => {
.spyOn(authServiceClient.tenant, 'create')
.mockImplementationOnce(async () => undefined)
const tenant = await tenantService.create(createOptions)
assert(!isTenantError(tenant))

const spy = jest
.spyOn(authServiceClient.tenant, 'delete')
Expand Down Expand Up @@ -370,6 +395,7 @@ describe('Tenant Service', (): void => {
.spyOn(authServiceClient.tenant, 'create')
.mockImplementationOnce(async () => undefined)
const tenant = await tenantService.create(createOptions)
assert(!isTenantError(tenant))

const spy = jest
.spyOn(authServiceClient.tenant, 'delete')
Expand Down Expand Up @@ -420,6 +446,7 @@ describe('Tenant Service', (): void => {

const spyCacheSet = jest.spyOn(tenantCache, 'set')
const tenant = await tenantService.create(createOptions)
assert(!isTenantError(tenant))
expect(tenant).toMatchObject({
...createOptions,
id: tenant.id
Expand Down
24 changes: 19 additions & 5 deletions packages/backend/src/tenants/service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validate as validateUuid } from 'uuid'
import { Tenant } from './model'
import { BaseService } from '../shared/baseService'
import { TransactionOrKnex } from 'objection'
Expand All @@ -7,12 +8,12 @@ import type { AuthServiceClient } from '../auth-service-client/client'
import { TenantSettingService } from './settings/service'
import { TenantSetting } from './settings/model'
import type { IAppConfig } from '../config/app'
import { TenantError } from './errors'
import { isTenantError, TenantError } from './errors'
import { TenantSettingInput } from '../graphql/generated/graphql'

export interface TenantService {
get: (id: string, includeDeleted?: boolean) => Promise<Tenant | undefined>
create: (options: CreateTenantOptions) => Promise<Tenant>
create: (options: CreateTenantOptions) => Promise<Tenant | TenantError>
update: (options: UpdateTenantOptions) => Promise<Tenant>
delete: (id: string) => Promise<void>
getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise<Tenant[]>
Expand Down Expand Up @@ -75,6 +76,7 @@ async function getTenantPage(
}

interface CreateTenantOptions {
id?: string
email?: string
apiSecret: string
idpSecret?: string
Expand All @@ -86,12 +88,23 @@ interface CreateTenantOptions {
async function createTenant(
deps: ServiceDependencies,
options: CreateTenantOptions
): Promise<Tenant> {
): Promise<Tenant | TenantError> {
const trx = await deps.knex.transaction()
try {
const { email, apiSecret, publicName, idpSecret, idpConsentUrl, settings } =
options
const {
id,
email,
apiSecret,
publicName,
idpSecret,
idpConsentUrl,
settings
} = options
if (id && !validateUuid(id)) {
throw TenantError.InvalidTenantId
}
const tenant = await Tenant.query(trx).insertAndFetch({
id,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe a quick uuidv4 validation here so we can specify a good error message to the client

email,
publicName,
apiSecret,
Expand Down Expand Up @@ -125,6 +138,7 @@ async function createTenant(
return tenant
} catch (err) {
await trx.rollback()
if (isTenantError(err)) return err
throw err
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/tenants/settings/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { AuthServiceClient } from '../../auth-service-client/client'
import { v4 as uuid } from 'uuid'
import { createTenant } from '../../tests/tenant'
import { isTenantSettingError, TenantSettingError } from './errors'
import { isTenantError } from '../errors'

describe('TenantSetting Service', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -50,12 +51,14 @@ describe('TenantSetting Service', (): void => {
.spyOn(authServiceClient.tenant, 'delete')
.mockResolvedValueOnce(undefined)

tenant = await tenantService.create({
const tenantOrError = await tenantService.create({
apiSecret: faker.string.uuid(),
email: faker.internet.email(),
idpConsentUrl: faker.internet.url(),
idpSecret: faker.string.uuid()
})
assert(!isTenantError(tenantOrError))
tenant = tenantOrError
})

afterEach(async (): Promise<void> => {
Expand Down
7 changes: 4 additions & 3 deletions packages/backend/src/tests/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { TestContainer } from './app'
import { isTenantError } from '../tenants/errors'

interface CreateOptions {
email: string
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function createTenant(
jest
.spyOn(authServiceClient.tenant, 'create')
.mockImplementationOnce(async () => undefined)
const tenant = await tenantService.create(
const tenantOrError = await tenantService.create(
options || {
email: faker.internet.email(),
apiSecret: 'test-api-secret',
Expand All @@ -85,9 +86,9 @@ export async function createTenant(
}
)

if (!tenant) {
if (!tenantOrError || isTenantError(tenantOrError)) {
throw Error('Failed to create test tenant')
}

return tenant
return tenantOrError
}
2 changes: 2 additions & 0 deletions packages/frontend/app/generated/graphql.ts

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

Loading
Loading