mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
test(cloud): add /tenants tests
This commit is contained in:
parent
af4276e08e
commit
6182747363
8 changed files with 161 additions and 40 deletions
|
@ -1,5 +1,10 @@
|
||||||
import baseConfig from '@silverhand/jest-config';
|
import baseConfig from '@silverhand/jest-config';
|
||||||
|
|
||||||
/** @type {import('jest').Config} */
|
/** @type {import('jest').Config} */
|
||||||
const config = { ...baseConfig, roots: ['./build'] };
|
const config = {
|
||||||
|
...baseConfig,
|
||||||
|
coveragePathIgnorePatterns: ['/node_modules/', '/test-utils/'],
|
||||||
|
collectCoverageFrom: ['**/*.js'],
|
||||||
|
roots: ['./build'],
|
||||||
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
@ -7,7 +7,7 @@ import router from './index.js';
|
||||||
describe('GET /api/status', () => {
|
describe('GET /api/status', () => {
|
||||||
it('should set status to 204', async () => {
|
it('should set status to 204', async () => {
|
||||||
await router.routes()(
|
await router.routes()(
|
||||||
buildRequestContext('/api/status'),
|
buildRequestContext('GET /api/status'),
|
||||||
async ({ status, json, stream }) => {
|
async ({ status, json, stream }) => {
|
||||||
expect(status).toBe(204);
|
expect(status).toBe(204);
|
||||||
expect(json).toBe(undefined);
|
expect(json).toBe(undefined);
|
||||||
|
@ -21,7 +21,7 @@ describe('GET /api/status', () => {
|
||||||
describe('GET /api/teapot', () => {
|
describe('GET /api/teapot', () => {
|
||||||
it('should refuse to brew coffee', async () => {
|
it('should refuse to brew coffee', async () => {
|
||||||
await router.routes()(
|
await router.routes()(
|
||||||
buildRequestContext('/api/teapot'),
|
buildRequestContext('GET /api/teapot'),
|
||||||
async ({ status, json, stream }) => {
|
async ({ status, json, stream }) => {
|
||||||
expect(status).toBe(418);
|
expect(status).toBe(418);
|
||||||
expect(isKeyInObject(json, 'message')).toBeTruthy();
|
expect(isKeyInObject(json, 'message')).toBeTruthy();
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import { createRouter } from '@withtyped/server';
|
import { createRouter } from '@withtyped/server';
|
||||||
|
|
||||||
|
import { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||||
|
import { Queries } from '#src/queries/index.js';
|
||||||
|
|
||||||
import { tenants } from './tenants.js';
|
import { createTenants } from './tenants.js';
|
||||||
|
|
||||||
const router = createRouter<WithAuthContext, '/api'>('/api').pack(tenants);
|
const router = createRouter<WithAuthContext, '/api'>('/api').pack(
|
||||||
|
createTenants(new TenantsLibrary(Queries.default))
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
79
packages/cloud/src/routes/tenants.test.ts
Normal file
79
packages/cloud/src/routes/tenants.test.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import type { TenantInfo } from '@logto/schemas';
|
||||||
|
import { CloudScope } from '@logto/schemas';
|
||||||
|
|
||||||
|
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
|
||||||
|
import { noop } from '#src/test-utils/function.js';
|
||||||
|
import { MockTenantsLibrary } from '#src/test-utils/libraries.js';
|
||||||
|
|
||||||
|
import { createTenants } from './tenants.js';
|
||||||
|
|
||||||
|
describe('GET /api/tenants', () => {
|
||||||
|
const library = new MockTenantsLibrary();
|
||||||
|
const router = createTenants(library);
|
||||||
|
|
||||||
|
it('should return whatever the library returns', async () => {
|
||||||
|
const tenants: TenantInfo[] = [{ id: 'tenant_a', indicator: 'https://foo.bar' }];
|
||||||
|
library.getAvailableTenants.mockResolvedValueOnce(tenants);
|
||||||
|
|
||||||
|
await router.routes()(
|
||||||
|
buildRequestAuthContext('GET /tenants')(),
|
||||||
|
async ({ json }) => {
|
||||||
|
expect(json).toBe(tenants);
|
||||||
|
},
|
||||||
|
createHttpContext()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/tenants', () => {
|
||||||
|
const library = new MockTenantsLibrary();
|
||||||
|
const router = createTenants(library);
|
||||||
|
|
||||||
|
it('should throw 403 when lack of permission', async () => {
|
||||||
|
await expect(
|
||||||
|
router.routes()(buildRequestAuthContext('POST /tenants')(), noop, createHttpContext())
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw 409 when user has a tenant', async () => {
|
||||||
|
const tenant: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' };
|
||||||
|
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
router.routes()(
|
||||||
|
buildRequestAuthContext('POST /tenants')([CloudScope.CreateTenant]),
|
||||||
|
noop,
|
||||||
|
createHttpContext()
|
||||||
|
)
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to create a new tenant', async () => {
|
||||||
|
const tenant: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' };
|
||||||
|
library.getAvailableTenants.mockResolvedValueOnce([]);
|
||||||
|
library.createNewTenant.mockResolvedValueOnce(tenant);
|
||||||
|
|
||||||
|
await router.routes()(
|
||||||
|
buildRequestAuthContext('POST /tenants')([CloudScope.CreateTenant]),
|
||||||
|
async ({ json }) => {
|
||||||
|
expect(json).toBe(tenant);
|
||||||
|
},
|
||||||
|
createHttpContext()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to create a new tenant with `manage:tenant` scope even if user has a tenant', async () => {
|
||||||
|
const tenantA: TenantInfo = { id: 'tenant_a', indicator: 'https://foo.bar' };
|
||||||
|
const tenantB: TenantInfo = { id: 'tenant_b', indicator: 'https://foo.baz' };
|
||||||
|
library.getAvailableTenants.mockResolvedValueOnce([tenantA]);
|
||||||
|
library.createNewTenant.mockResolvedValueOnce(tenantB);
|
||||||
|
|
||||||
|
await router.routes()(
|
||||||
|
buildRequestAuthContext('POST /tenants')([CloudScope.ManageTenant]),
|
||||||
|
async ({ json }) => {
|
||||||
|
expect(json).toBe(tenantB);
|
||||||
|
},
|
||||||
|
createHttpContext()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,30 +1,29 @@
|
||||||
import { CloudScope } from '@logto/schemas';
|
import { CloudScope } from '@logto/schemas';
|
||||||
import { createRouter, RequestError } from '@withtyped/server';
|
import { createRouter, RequestError } from '@withtyped/server';
|
||||||
|
|
||||||
import { tenantInfoGuard, TenantsLibrary } from '#src/libraries/tenants.js';
|
import type { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||||
|
import { tenantInfoGuard } from '#src/libraries/tenants.js';
|
||||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||||
import { Queries } from '#src/queries/index.js';
|
|
||||||
|
|
||||||
const library = new TenantsLibrary(Queries.default);
|
export const createTenants = (library: TenantsLibrary) =>
|
||||||
|
createRouter<WithAuthContext, '/tenants'>('/tenants')
|
||||||
|
.get('/', { response: tenantInfoGuard.array() }, async (context, next) => {
|
||||||
|
return next({ ...context, json: await library.getAvailableTenants(context.auth.id) });
|
||||||
|
})
|
||||||
|
.post('/', { response: tenantInfoGuard }, async (context, next) => {
|
||||||
|
if (
|
||||||
|
![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) =>
|
||||||
|
context.auth.scopes.includes(scope)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
export const tenants = createRouter<WithAuthContext, '/tenants'>('/tenants')
|
const tenants = await library.getAvailableTenants(context.auth.id);
|
||||||
.get('/', { response: tenantInfoGuard.array() }, async (context, next) => {
|
|
||||||
return next({ ...context, json: await library.getAvailableTenants(context.auth.id) });
|
|
||||||
})
|
|
||||||
.post('/', { response: tenantInfoGuard }, async (context, next) => {
|
|
||||||
if (
|
|
||||||
![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) =>
|
|
||||||
context.auth.scopes.includes(scope)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenants = await library.getAvailableTenants(context.auth.id);
|
if (!context.auth.scopes.includes(CloudScope.ManageTenant) && tenants.length > 0) {
|
||||||
|
throw new RequestError('The user already has a tenant.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.auth.scopes.includes(CloudScope.ManageTenant) && tenants.length > 0) {
|
return next({ ...context, json: await library.createNewTenant(context.auth.id) });
|
||||||
throw new RequestError('The user already has a tenant.', 409);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return next({ ...context, json: await library.createNewTenant(context.auth.id) });
|
|
||||||
});
|
|
||||||
|
|
|
@ -2,8 +2,9 @@ import { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import { Socket } from 'node:net';
|
import { Socket } from 'node:net';
|
||||||
import { TLSSocket } from 'node:tls';
|
import { TLSSocket } from 'node:tls';
|
||||||
|
|
||||||
import type { HttpContext, RequestContext } from '@withtyped/server';
|
import type { HttpContext, RequestContext, RequestMethod } from '@withtyped/server';
|
||||||
import { RequestMethod } from '@withtyped/server';
|
|
||||||
|
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||||
|
|
||||||
export const createHttpContext: (isHttps?: boolean) => HttpContext = (isHttps = false) => {
|
export const createHttpContext: (isHttps?: boolean) => HttpContext = (isHttps = false) => {
|
||||||
const request = new IncomingMessage(isHttps ? new TLSSocket(new Socket()) : new Socket());
|
const request = new IncomingMessage(isHttps ? new TLSSocket(new Socket()) : new Socket());
|
||||||
|
@ -16,14 +17,30 @@ export const createHttpContext: (isHttps?: boolean) => HttpContext = (isHttps =
|
||||||
|
|
||||||
type BuildRequestContext = Partial<RequestContext['request']>;
|
type BuildRequestContext = Partial<RequestContext['request']>;
|
||||||
|
|
||||||
export const buildRequestContext = (
|
const splitPath = <Pathname extends string, Path extends `${RequestMethod} ${Pathname}`>(
|
||||||
pathname: string,
|
path: Path
|
||||||
{
|
): [RequestMethod, Pathname] => {
|
||||||
method = RequestMethod.GET,
|
const [method, ...rest] = path.split(' ');
|
||||||
headers = {},
|
|
||||||
url = new URL(pathname, 'http://localhost'),
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
body,
|
return [method, rest.join('')] as [RequestMethod, Pathname];
|
||||||
}: BuildRequestContext = {}
|
};
|
||||||
): RequestContext => ({
|
|
||||||
request: { method, headers, url, body },
|
export const buildRequestContext = <Path extends `${RequestMethod} ${string}`>(
|
||||||
});
|
path: Path,
|
||||||
|
{ headers = {}, body }: BuildRequestContext = {}
|
||||||
|
): RequestContext => {
|
||||||
|
const [method, pathname] = splitPath(path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: { method, headers, url: new URL(pathname, 'http://localhost'), body },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildRequestAuthContext =
|
||||||
|
<Path extends `${RequestMethod} ${string}`>(
|
||||||
|
...args: Parameters<typeof buildRequestContext<Path>>
|
||||||
|
) =>
|
||||||
|
(scopes: string[] = []): WithAuthContext => {
|
||||||
|
return { ...buildRequestContext(...args), auth: { id: 'foo', scopes } };
|
||||||
|
};
|
||||||
|
|
2
packages/cloud/src/test-utils/function.ts
Normal file
2
packages/cloud/src/test-utils/function.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
export const noop = async () => {};
|
15
packages/cloud/src/test-utils/libraries.ts
Normal file
15
packages/cloud/src/test-utils/libraries.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { TenantInfo } from '@logto/schemas';
|
||||||
|
|
||||||
|
import type { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||||
|
import type { Queries } from '#src/queries/index.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
|
||||||
|
export class MockTenantsLibrary implements TenantsLibrary {
|
||||||
|
public get queries(): Queries {
|
||||||
|
throw new Error('Not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAvailableTenants = jest.fn<Promise<TenantInfo[]>, [string]>();
|
||||||
|
public createNewTenant = jest.fn<Promise<TenantInfo>, [string]>();
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue