0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

Merge pull request #3316 from logto-io/gao-add-cloud-api-tests

test(cloud): init cloud unit tests
This commit is contained in:
Gao Sun 2023-03-08 11:24:18 +08:00 committed by GitHub
commit 1ca42e0573
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 667 additions and 1006 deletions

View file

@ -70,14 +70,14 @@
"@silverhand/eslint-config": "2.0.1",
"@silverhand/ts-config": "2.0.3",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",
"@types/tar": "^6.1.2",
"@types/yargs": "^17.0.13",
"eslint": "^8.34.0",
"jest": "^29.3.1",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"prettier": "^2.8.2",
"sinon": "^15.0.0",

View file

@ -0,0 +1,10 @@
import baseConfig from '@silverhand/jest-config';
/** @type {import('jest').Config} */
const config = {
...baseConfig,
coveragePathIgnorePatterns: ['/node_modules/', '/test-utils/'],
collectCoverageFrom: ['**/*.js'],
roots: ['./build'],
};
export default config;

View file

@ -13,10 +13,14 @@
"scripts": {
"precommit": "lint-staged",
"build": "rm -rf build/ && tsc -p tsconfig.build.json",
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"dev": "rm -rf build/ && nodemon",
"start": "NODE_ENV=production node ."
"start": "NODE_ENV=production node .",
"test:only": "NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" jest --logHeapUsage",
"test": "pnpm build:test && pnpm test:only",
"test:ci": "pnpm test:only --coverage --silent"
},
"dependencies": {
"@logto/cli": "workspace:*",
@ -38,11 +42,14 @@
},
"devDependencies": {
"@silverhand/eslint-config": "2.0.1",
"@silverhand/jest-config": "^2.0.1",
"@silverhand/ts-config": "2.0.3",
"@types/http-proxy": "^1.17.9",
"@types/jest": "^29.4.0",
"@types/mime-types": "^2.1.1",
"@types/node": "^18.11.18",
"eslint": "^8.21.0",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"nodemon": "^2.0.19",
"prettier": "^2.8.1",

View file

@ -1,8 +1,8 @@
import assert from 'node:assert';
import type { IncomingHttpHeaders } from 'node:http';
import path from 'node:path/posix';
import { tryThat } from '@logto/shared';
import { appendPath } from '@silverhand/essentials';
import type { NextFunction, RequestContext } from '@withtyped/server';
import { RequestError } from '@withtyped/server';
import fetchRetry from 'fetch-retry';
@ -50,9 +50,7 @@ export default function withAuth<InputContext extends RequestContext>({
const fetch = fetchRetry(global.fetch);
const getJwkSet = (async () => {
const fetched = await fetch(
new Request(
new URL(path.join(endpoint.pathname, 'oidc/.well-known/openid-configuration'), endpoint)
),
new Request(appendPath(endpoint, 'oidc/.well-known/openid-configuration')),
{ retries: 5, retryDelay: (attempt) => 2 ** attempt * 1000 }
);
const { jwks_uri: jwksUri, issuer } = z

View file

@ -0,0 +1,33 @@
import { isKeyInObject } from '@logto/shared';
import { buildRequestContext, createHttpContext } from '#src/test-utils/context.js';
import router from './index.js';
describe('GET /api/status', () => {
it('should set status to 204', async () => {
await router.routes()(
buildRequestContext('GET /api/status'),
async ({ status, json, stream }) => {
expect(status).toBe(204);
expect(json).toBe(undefined);
expect(stream).toBe(undefined);
},
createHttpContext()
);
});
});
describe('GET /api/teapot', () => {
it('should refuse to brew coffee', async () => {
await router.routes()(
buildRequestContext('GET /api/teapot'),
async ({ status, json, stream }) => {
expect(status).toBe(418);
expect(isKeyInObject(json, 'message')).toBeTruthy();
expect(stream).toBe(undefined);
},
createHttpContext()
);
});
});

View file

@ -1,9 +1,13 @@
import { createRouter } from '@withtyped/server';
import { TenantsLibrary } from '#src/libraries/tenants.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;

View 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()
);
});
});

View file

@ -1,30 +1,29 @@
import { CloudScope } from '@logto/schemas';
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 { 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')
.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);
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) {
throw new RequestError('The user already has a tenant.', 409);
}
return next({ ...context, json: await library.createNewTenant(context.auth.id) });
});
return next({ ...context, json: await library.createNewTenant(context.auth.id) });
});

View file

@ -0,0 +1,46 @@
import { IncomingMessage, ServerResponse } from 'node:http';
import { Socket } from 'node:net';
import { TLSSocket } from 'node:tls';
import type { HttpContext, RequestContext, RequestMethod } from '@withtyped/server';
import type { WithAuthContext } from '#src/middleware/with-auth.js';
export const createHttpContext: (isHttps?: boolean) => HttpContext = (isHttps = false) => {
const request = new IncomingMessage(isHttps ? new TLSSocket(new Socket()) : new Socket());
return {
request,
response: new ServerResponse(request),
};
};
type BuildRequestContext = Partial<RequestContext['request']>;
const splitPath = <Pathname extends string, Path extends `${RequestMethod} ${Pathname}`>(
path: Path
): [RequestMethod, Pathname] => {
const [method, ...rest] = path.split(' ');
// eslint-disable-next-line no-restricted-syntax
return [method, rest.join('')] as [RequestMethod, Pathname];
};
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 } };
};

View file

@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const noop = async () => {};

View 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]>();
}

View file

@ -1,7 +1,7 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"types": ["node"],
"types": ["node", "jest"],
"declaration": false,
"outDir": "build",
"baseUrl": ".",

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true,
}
}

View file

@ -80,7 +80,7 @@
"@types/debug": "^4.1.7",
"@types/etag": "^1.8.1",
"@types/http-errors": "^1.8.2",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/js-yaml": "^4.0.5",
"@types/koa": "^2.13.3",
"@types/koa-compose": "^3.2.5",
@ -96,7 +96,7 @@
"copyfiles": "^2.4.1",
"eslint": "^8.34.0",
"http-errors": "^1.6.3",
"jest": "^29.1.2",
"jest": "^29.5.0",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0",
"node-mocks-http": "^1.12.1",

View file

@ -31,13 +31,13 @@
"@silverhand/essentials": "2.4.0",
"@silverhand/ts-config": "2.0.3",
"@types/expect-puppeteer": "^5.0.3",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/jest-environment-puppeteer": "^5.0.3",
"@types/node": "^18.11.18",
"dotenv": "^16.0.0",
"eslint": "^8.34.0",
"got": "^12.5.3",
"jest": "^29.1.2",
"jest": "^29.5.0",
"jest-puppeteer": "^7.0.0",
"node-fetch": "^3.3.0",
"openapi-schema-validator": "^12.0.0",

View file

@ -44,13 +44,13 @@
"@silverhand/essentials": "2.4.0",
"@silverhand/ts-config": "2.0.3",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@types/pluralize": "^0.0.29",
"camelcase": "^7.0.0",
"chalk": "^5.0.0",
"eslint": "^8.34.0",
"jest": "^29.1.2",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"pluralize": "^8.0.0",
"prettier": "^2.8.2",

View file

@ -35,10 +35,10 @@
"@logto/connector-kit": "workspace:*",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/ts-config": "2.0.3",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"eslint": "^8.34.0",
"jest": "^29.1.2",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"prettier": "^2.8.2",
"typescript": "^4.9.4"

View file

@ -53,11 +53,11 @@
"@silverhand/essentials": "2.4.0",
"@silverhand/ts-config": "2.0.3",
"@types/color": "^3.0.3",
"@types/jest": "^29.0.3",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@types/react": "^18.0.20",
"eslint": "^8.34.0",
"jest": "^29.0.3",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"postcss": "^8.4.6",
"prettier": "^2.8.2",

View file

@ -41,10 +41,10 @@
"@jest/types": "^29.0.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/ts-config": "2.0.3",
"@types/jest": "^29.0.3",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"eslint": "^8.34.0",
"jest": "^29.0.3",
"jest": "^29.5.0",
"lint-staged": "^13.0.0",
"prettier": "^2.8.2",
"tslib": "^2.4.1",

View file

@ -37,7 +37,7 @@
"@silverhand/ts-config-react": "2.0.3",
"@testing-library/react": "^14.0.0",
"@types/color": "^3.0.3",
"@types/jest": "^29.1.2",
"@types/jest": "^29.4.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react-modal": "^3.13.1",
@ -49,7 +49,7 @@
"eslint": "^8.34.0",
"i18next": "^21.8.16",
"i18next-browser-languagedetector": "^6.1.4",
"jest": "^29.1.2",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.0.0",
"jest-transformer-svg": "^2.0.0",
"js-base64": "^3.7.2",

1381
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff