feat(backend): 实现 Cool-Admin 多租户与组织架构上下文模块及拦截中间件
这个提交包含在:
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/test/**/*.test.ts'],
|
||||
testPathIgnorePatterns: ['<rootDir>/test/fixtures'],
|
||||
coveragePathIgnorePatterns: ['<rootDir>/test/'],
|
||||
};
|
||||
113
backend/src/comm/context/tenant.ts
普通文件
113
backend/src/comm/context/tenant.ts
普通文件
@@ -0,0 +1,113 @@
|
||||
import { Context } from '@midwayjs/koa';
|
||||
|
||||
/**
|
||||
* 租户上下文元数据契约
|
||||
*/
|
||||
export interface TenantContextInfo {
|
||||
/** 租户 ID */
|
||||
tenantId: number | string;
|
||||
/** 关联名片 ID (可选) */
|
||||
cardId?: number | string;
|
||||
/** 关联用户 ID (可选) */
|
||||
userId?: number | string;
|
||||
/** 是否平台超级管理员 / 超级租户 */
|
||||
isSuper?: boolean;
|
||||
/** 租户角色 / 权限标识 (可选) */
|
||||
role?: string;
|
||||
/** 扩展属性 */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 默认兜底租户 ID (平台超级租户 / 本地开发兜底) */
|
||||
export const DEFAULT_TENANT_ID = 1;
|
||||
|
||||
/** 租户上下文在 Context 中的唯一 Symbol 键 */
|
||||
export const TENANT_CONTEXT_KEY = Symbol('CRMMP_TENANT_CONTEXT');
|
||||
|
||||
/**
|
||||
* 设置当前请求的租户上下文
|
||||
* 同步保证与 Cool-Admin 现存的 ctx.admin.tenantId / ctx.user.tenantId 机制完全兼容
|
||||
*/
|
||||
export function setTenantContext(
|
||||
ctx: Context,
|
||||
tenantInfo: TenantContextInfo
|
||||
): void {
|
||||
if (!ctx) return;
|
||||
const contextObj = ctx as unknown as Record<string | symbol, unknown>;
|
||||
contextObj[TENANT_CONTEXT_KEY] = tenantInfo;
|
||||
contextObj.tenant = tenantInfo;
|
||||
|
||||
// 同步兼容 Cool-Admin 原生 ctx.admin 与 ctx.user (如 TenantSubscriber 数据隔离订阅者)
|
||||
if (contextObj.admin && typeof contextObj.admin === 'object') {
|
||||
(contextObj.admin as Record<string, unknown>).tenantId =
|
||||
tenantInfo.tenantId;
|
||||
}
|
||||
if (contextObj.user && typeof contextObj.user === 'object') {
|
||||
(contextObj.user as Record<string, unknown>).tenantId = tenantInfo.tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的租户上下文完整对象
|
||||
*/
|
||||
export function getTenantContext(ctx: Context): TenantContextInfo | undefined {
|
||||
if (!ctx) return undefined;
|
||||
const contextObj = ctx as unknown as Record<string | symbol, unknown>;
|
||||
return (
|
||||
(contextObj[TENANT_CONTEXT_KEY] as TenantContextInfo | undefined) ??
|
||||
(contextObj.tenant as TenantContextInfo | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取当前租户 ID
|
||||
* 优先级:
|
||||
* 1. ctx.tenant.tenantId
|
||||
* 2. ctx.admin.tenantId (Cool-Admin 原生后台)
|
||||
* 3. ctx.user.tenantId (Cool-Admin 原生客户端)
|
||||
*/
|
||||
export function getTenantId(ctx: Context): number | string | undefined {
|
||||
if (!ctx) return undefined;
|
||||
const contextInfo = getTenantContext(ctx);
|
||||
if (
|
||||
contextInfo &&
|
||||
contextInfo.tenantId !== undefined &&
|
||||
contextInfo.tenantId !== null &&
|
||||
contextInfo.tenantId !== ''
|
||||
) {
|
||||
return contextInfo.tenantId;
|
||||
}
|
||||
const contextObj = ctx as unknown as Record<string, unknown>;
|
||||
const adminTenant = (contextObj.admin as Record<string, unknown> | undefined)
|
||||
?.tenantId;
|
||||
const userTenant = (contextObj.user as Record<string, unknown> | undefined)
|
||||
?.tenantId;
|
||||
return (
|
||||
(adminTenant as number | string | undefined) ??
|
||||
(userTenant as number | string | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取当前请求的名片 ID
|
||||
*/
|
||||
export function getCardId(ctx: Context): number | string | undefined {
|
||||
if (!ctx) return undefined;
|
||||
return getTenantContext(ctx)?.cardId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否为平台超级租户或系统超管
|
||||
*/
|
||||
export function isSuperTenant(ctx: Context): boolean {
|
||||
if (!ctx) return false;
|
||||
const contextInfo = getTenantContext(ctx);
|
||||
if (contextInfo?.isSuper) return true;
|
||||
const currentTenantId = getTenantId(ctx);
|
||||
if (currentTenantId === DEFAULT_TENANT_ID || currentTenantId === 'default')
|
||||
return true;
|
||||
const contextObj = ctx as unknown as Record<string, unknown>;
|
||||
const adminUser = (contextObj.admin as Record<string, unknown> | undefined)
|
||||
?.username;
|
||||
return adminUser === 'admin';
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Config, Middleware } from '@midwayjs/core';
|
||||
import { NextFunction, Context } from '@midwayjs/koa';
|
||||
import { IMiddleware } from '@midwayjs/core';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import {
|
||||
setTenantContext,
|
||||
DEFAULT_TENANT_ID,
|
||||
TenantContextInfo,
|
||||
} from '../context/tenant';
|
||||
|
||||
/**
|
||||
* 租户中间件配置契约
|
||||
*/
|
||||
export interface TenantMiddlewareConfig {
|
||||
enable?: boolean;
|
||||
urls?: string[];
|
||||
strict?: boolean;
|
||||
defaultTenantId?: number | string;
|
||||
ignoreUrls?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 多租户与组织架构上下文拦截中间件
|
||||
*
|
||||
* 1. 拦截 HTTP 请求头中的 x-tenant-id 与 x-card-id
|
||||
* 2. 解析鉴权 Token 中的 tenantId、cardId 与用户身份
|
||||
* 3. 注入租户上下文并与 ctx.admin / ctx.user 同步
|
||||
* 4. 对缺失租户上下文的受保护路由提供严格校验或默认兜底
|
||||
*/
|
||||
@Middleware()
|
||||
export class TenantMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
@Config('koa.globalPrefix')
|
||||
prefix = '';
|
||||
|
||||
@Config('cool.tenant')
|
||||
tenantConfig?: TenantMiddlewareConfig;
|
||||
|
||||
/**
|
||||
* 默认开放公共路径白名单
|
||||
*/
|
||||
private readonly defaultIgnoreUrls: string[] = [
|
||||
'/admin/base/open/',
|
||||
'/app/base/open/',
|
||||
'/public/',
|
||||
'/upload/',
|
||||
'/swagger-ui/',
|
||||
'/api-docs',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
resolve() {
|
||||
return async (ctx: Context, next: NextFunction) => {
|
||||
// 1. 规范化路由路径 (剔除 globalPrefix 与 query string)
|
||||
let url = ctx.url || '';
|
||||
if (this.prefix && url.startsWith(this.prefix)) {
|
||||
url = url.slice(this.prefix.length);
|
||||
}
|
||||
const cleanPath = url.split('?')[0];
|
||||
|
||||
// 2. 从 HTTP Header 提取 x-tenant-id 与 x-card-id
|
||||
const headerTenantId = ctx.get('x-tenant-id');
|
||||
const headerCardId = ctx.get('x-card-id');
|
||||
|
||||
// 3. 从 Token 或已有上下文解析租户与身份
|
||||
let tokenTenantId: string | number | undefined;
|
||||
let tokenCardId: string | number | undefined;
|
||||
let tokenUserId: string | number | undefined;
|
||||
let isSuper = false;
|
||||
|
||||
const contextObj = ctx as unknown as Record<string, unknown>;
|
||||
const adminObj = contextObj.admin as Record<string, unknown> | undefined;
|
||||
const userObj = contextObj.user as Record<string, unknown> | undefined;
|
||||
|
||||
if (adminObj) {
|
||||
tokenTenantId = adminObj.tenantId as string | number | undefined;
|
||||
tokenUserId = adminObj.userId as string | number | undefined;
|
||||
if (adminObj.username === 'admin') {
|
||||
isSuper = true;
|
||||
}
|
||||
} else if (userObj) {
|
||||
tokenTenantId = userObj.tenantId as string | number | undefined;
|
||||
tokenUserId = userObj.userId as string | number | undefined;
|
||||
}
|
||||
|
||||
// 若未获取但携带 Authorization 头,安全解码 payload
|
||||
const authHeader = ctx.get('authorization') || ctx.get('Authorization');
|
||||
if (!tokenTenantId && authHeader) {
|
||||
try {
|
||||
const rawToken = authHeader.replace(/^Bearer\s+/i, '');
|
||||
const decoded = jwt.decode(rawToken) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (decoded && typeof decoded === 'object') {
|
||||
tokenTenantId =
|
||||
(decoded.tenantId as string | number | undefined) ??
|
||||
tokenTenantId;
|
||||
tokenCardId =
|
||||
(decoded.cardId as string | number | undefined) ?? tokenCardId;
|
||||
tokenUserId =
|
||||
tokenUserId ?? (decoded.userId as string | number | undefined);
|
||||
if (decoded.username === 'admin' || decoded.isSuper === true) {
|
||||
isSuper = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token 解码失败忽略,由后续鉴权中间件严格拦截
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 优先级判定:HTTP Header > Token/Context
|
||||
const rawTenantId = headerTenantId || tokenTenantId;
|
||||
const parsedTenantId =
|
||||
rawTenantId !== undefined && rawTenantId !== ''
|
||||
? isNaN(Number(rawTenantId))
|
||||
? rawTenantId
|
||||
: Number(rawTenantId)
|
||||
: undefined;
|
||||
|
||||
const rawCardId = headerCardId || tokenCardId;
|
||||
const parsedCardId =
|
||||
rawCardId !== undefined && rawCardId !== ''
|
||||
? isNaN(Number(rawCardId))
|
||||
? rawCardId
|
||||
: Number(rawCardId)
|
||||
: undefined;
|
||||
|
||||
// 5. 检查是否为白名单路由
|
||||
const isIgnored = this.isPathIgnored(cleanPath);
|
||||
|
||||
// 6. 受保护路由校验与兜底
|
||||
let finalTenantId = parsedTenantId;
|
||||
|
||||
if (!finalTenantId) {
|
||||
if (isSuper) {
|
||||
finalTenantId =
|
||||
this.tenantConfig?.defaultTenantId ?? DEFAULT_TENANT_ID;
|
||||
} else if (!isIgnored && this.isProtectedPath(cleanPath)) {
|
||||
if (this.tenantConfig?.strict) {
|
||||
ctx.status = 400;
|
||||
throw new CoolCommException('缺少租户上下文~', 400);
|
||||
} else {
|
||||
finalTenantId =
|
||||
this.tenantConfig?.defaultTenantId ?? DEFAULT_TENANT_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 注入上下文
|
||||
if (finalTenantId !== undefined) {
|
||||
const tenantInfo: TenantContextInfo = {
|
||||
tenantId: finalTenantId,
|
||||
cardId: parsedCardId,
|
||||
userId: tokenUserId,
|
||||
isSuper,
|
||||
};
|
||||
setTenantContext(ctx, tenantInfo);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否命中免除租户校验白名单
|
||||
*/
|
||||
private isPathIgnored(path: string): boolean {
|
||||
const customIgnores = this.tenantConfig?.ignoreUrls || [];
|
||||
const allIgnores = [...this.defaultIgnoreUrls, ...customIgnores];
|
||||
return allIgnores.some(prefix => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否属于需要租户隔离的受保护路由
|
||||
*/
|
||||
private isProtectedPath(path: string): boolean {
|
||||
return path.startsWith('/admin/') || path.startsWith('/app/');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import * as LocalConfig from './config/config.local';
|
||||
import * as ProdConfig from './config/config.prod';
|
||||
import * as cool from '@cool-midway/core';
|
||||
import * as upload from '@midwayjs/upload';
|
||||
import { TenantMiddleware } from './comm/middleware/tenant';
|
||||
// import * as task from '@cool-midway/task';
|
||||
// import * as rpc from '@cool-midway/rpc';
|
||||
|
||||
@@ -66,5 +67,7 @@ export class MainConfiguration {
|
||||
@Inject()
|
||||
logger: ILogger;
|
||||
|
||||
async onReady() {}
|
||||
async onReady() {
|
||||
this.app.useMiddleware(TenantMiddleware);
|
||||
}
|
||||
}
|
||||
|
||||
273
backend/test/comm/tenant.test.ts
普通文件
273
backend/test/comm/tenant.test.ts
普通文件
@@ -0,0 +1,273 @@
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {
|
||||
DEFAULT_TENANT_ID,
|
||||
TENANT_CONTEXT_KEY,
|
||||
getCardId,
|
||||
getTenantContext,
|
||||
getTenantId,
|
||||
isSuperTenant,
|
||||
setTenantContext,
|
||||
TenantContextInfo,
|
||||
} from '../../src/comm/context/tenant';
|
||||
import { TenantMiddleware } from '../../src/comm/middleware/tenant';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
|
||||
describe('多租户与组织架构上下文模块 (Tenant Context & Middleware)', () => {
|
||||
const mockJwtSecret = 'omnicard-secret-2026';
|
||||
|
||||
describe('1. 租户上下文工具函数 (comm/context/tenant.ts)', () => {
|
||||
it('空上下文或未设置时应安全返回 undefined 或 false', () => {
|
||||
const emptyCtx: any = {};
|
||||
expect(getTenantId(emptyCtx)).toBeUndefined();
|
||||
expect(getCardId(emptyCtx)).toBeUndefined();
|
||||
expect(getTenantContext(emptyCtx)).toBeUndefined();
|
||||
expect(isSuperTenant(emptyCtx)).toBe(false);
|
||||
|
||||
expect(getTenantId(undefined as any)).toBeUndefined();
|
||||
expect(getCardId(undefined as any)).toBeUndefined();
|
||||
expect(getTenantContext(undefined as any)).toBeUndefined();
|
||||
expect(isSuperTenant(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('setTenantContext 应正确绑定上下文并与 ctx.admin / ctx.user 同步', () => {
|
||||
const ctx: any = {
|
||||
admin: { userId: 10, username: 'tester' },
|
||||
user: { userId: 20 },
|
||||
};
|
||||
|
||||
const tenantInfo: TenantContextInfo = {
|
||||
tenantId: 888,
|
||||
cardId: 999,
|
||||
userId: 10,
|
||||
isSuper: false,
|
||||
role: 'tenant_admin',
|
||||
};
|
||||
|
||||
setTenantContext(ctx, tenantInfo);
|
||||
|
||||
expect(getTenantContext(ctx)).toEqual(tenantInfo);
|
||||
expect(ctx.tenant).toEqual(tenantInfo);
|
||||
expect(ctx[TENANT_CONTEXT_KEY]).toEqual(tenantInfo);
|
||||
expect(getTenantId(ctx)).toBe(888);
|
||||
expect(getCardId(ctx)).toBe(999);
|
||||
expect(isSuperTenant(ctx)).toBe(false);
|
||||
|
||||
// 同步验证对 Cool-Admin 原生字段的兼容性
|
||||
expect(ctx.admin.tenantId).toBe(888);
|
||||
expect(ctx.user.tenantId).toBe(888);
|
||||
});
|
||||
|
||||
it('当仅存在原生 ctx.admin.tenantId 或 ctx.user.tenantId 时,getTenantId 能够平滑兜底读取', () => {
|
||||
const adminCtx: any = { admin: { tenantId: 300 } };
|
||||
expect(getTenantId(adminCtx)).toBe(300);
|
||||
|
||||
const userCtx: any = { user: { tenantId: 'tenant_abc' } };
|
||||
expect(getTenantId(userCtx)).toBe('tenant_abc');
|
||||
});
|
||||
|
||||
it('isSuperTenant 应能准确识别超级管理员状态', () => {
|
||||
// 1. isSuper 显式为 true
|
||||
const superCtx1: any = {};
|
||||
setTenantContext(superCtx1, { tenantId: 99, isSuper: true });
|
||||
expect(isSuperTenant(superCtx1)).toBe(true);
|
||||
|
||||
// 2. tenantId 为 DEFAULT_TENANT_ID (1) 或 'default'
|
||||
const superCtx2: any = {};
|
||||
setTenantContext(superCtx2, { tenantId: DEFAULT_TENANT_ID });
|
||||
expect(isSuperTenant(superCtx2)).toBe(true);
|
||||
|
||||
const superCtx3: any = {};
|
||||
setTenantContext(superCtx3, { tenantId: 'default' });
|
||||
expect(isSuperTenant(superCtx3)).toBe(true);
|
||||
|
||||
// 3. admin 用户名为 'admin'
|
||||
const superCtx4: any = { admin: { username: 'admin' } };
|
||||
expect(isSuperTenant(superCtx4)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 多租户拦截中间件 (comm/middleware/tenant.ts)', () => {
|
||||
let middleware: TenantMiddleware;
|
||||
|
||||
beforeEach(() => {
|
||||
middleware = new TenantMiddleware();
|
||||
middleware.prefix = '';
|
||||
middleware.tenantConfig = {
|
||||
enable: true,
|
||||
strict: true,
|
||||
defaultTenantId: DEFAULT_TENANT_ID,
|
||||
ignoreUrls: ['/api/public/'],
|
||||
};
|
||||
});
|
||||
|
||||
it('应能从 HTTP Header (x-tenant-id 与 x-card-id) 中解析并注入上下文', async () => {
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/lead/page',
|
||||
headers: {
|
||||
'x-tenant-id': '10086',
|
||||
'x-card-id': '2001',
|
||||
},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
let nextCalled = false;
|
||||
const next = async () => {
|
||||
nextCalled = true;
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, next);
|
||||
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(getTenantId(ctx)).toBe(10086);
|
||||
expect(getCardId(ctx)).toBe(2001);
|
||||
expect(ctx.tenant.tenantId).toBe(10086);
|
||||
});
|
||||
|
||||
it('应能从 JWT Authorization 头解析 tenantId 与 cardId', async () => {
|
||||
const token = jwt.sign(
|
||||
{ userId: 101, tenantId: 666, cardId: 777, username: 'john' },
|
||||
mockJwtSecret
|
||||
);
|
||||
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/card/info',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
let nextCalled = false;
|
||||
const next = async () => {
|
||||
nextCalled = true;
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, next);
|
||||
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(getTenantId(ctx)).toBe(666);
|
||||
expect(getCardId(ctx)).toBe(777);
|
||||
expect(ctx.tenant.userId).toBe(101);
|
||||
});
|
||||
|
||||
it('当 Header 与 JWT 同时存在时,Header 优先级高于 JWT', async () => {
|
||||
const token = jwt.sign(
|
||||
{ userId: 101, tenantId: 666, cardId: 777 },
|
||||
mockJwtSecret
|
||||
);
|
||||
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/card/info',
|
||||
headers: {
|
||||
'x-tenant-id': '9999',
|
||||
'x-card-id': '8888',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, async () => {});
|
||||
|
||||
expect(getTenantId(ctx)).toBe(9999);
|
||||
expect(getCardId(ctx)).toBe(8888);
|
||||
});
|
||||
|
||||
it('针对免校验开放接口 (如 /admin/base/open/login),即便无租户信息也应直接放行', async () => {
|
||||
const ctx: any = {
|
||||
url: '/admin/base/open/login?type=pwd',
|
||||
headers: {},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
let nextCalled = false;
|
||||
const next = async () => {
|
||||
nextCalled = true;
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, next);
|
||||
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(getTenantId(ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('受保护路由在 strict=true 模式且无租户上下文时应抛出 400 异常', async () => {
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/lead/page',
|
||||
headers: {},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
|
||||
await expect(handler(ctx, async () => {})).rejects.toThrow(CoolCommException);
|
||||
expect(ctx.status).toBe(400);
|
||||
});
|
||||
|
||||
it('受保护路由在 strict=false 模式时应提供默认兜底租户 ID', async () => {
|
||||
middleware.tenantConfig.strict = false;
|
||||
middleware.tenantConfig.defaultTenantId = 1;
|
||||
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/lead/page',
|
||||
headers: {},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
let nextCalled = false;
|
||||
const next = async () => {
|
||||
nextCalled = true;
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, next);
|
||||
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(getTenantId(ctx)).toBe(1);
|
||||
});
|
||||
|
||||
it('超级管理员访问受保护路由时,即便缺失显式 Header 也自动兜底为平台超级租户', async () => {
|
||||
const superAdminToken = jwt.sign(
|
||||
{ userId: 1, username: 'admin' },
|
||||
mockJwtSecret
|
||||
);
|
||||
|
||||
const ctx: any = {
|
||||
url: '/admin/crm/lead/page',
|
||||
headers: {
|
||||
authorization: `Bearer ${superAdminToken}`,
|
||||
},
|
||||
get(key: string) {
|
||||
return this.headers[key.toLowerCase()];
|
||||
},
|
||||
};
|
||||
|
||||
let nextCalled = false;
|
||||
const next = async () => {
|
||||
nextCalled = true;
|
||||
};
|
||||
|
||||
const handler = middleware.resolve();
|
||||
await handler(ctx, next);
|
||||
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(getTenantId(ctx)).toBe(DEFAULT_TENANT_ID);
|
||||
expect(isSuperTenant(ctx)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,5 +26,6 @@
|
||||
"dist",
|
||||
"node_modules",
|
||||
"test",
|
||||
],
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户