补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
449 行
16 KiB
TypeScript
449 行
16 KiB
TypeScript
import * as dns from 'dns';
|
|
import { getMetadataArgsStorage } from 'typeorm';
|
|
import { CoolCommException } from '@cool-midway/core';
|
|
import { BaseEntity } from '../../../src/comm/entity/base';
|
|
import { TeamDomainEntity } from '../../../src/modules/team/entity/domain';
|
|
import { TeamDomainService } from '../../../src/modules/team/service/domain';
|
|
import { AdminTeamDomainController } from '../../../src/modules/team/controller/admin/domain';
|
|
import { SslCertMockInfo } from '../../../src/comm/types/omni';
|
|
|
|
describe('Point 22: 独立企业二级域名绑定向导与 SSL 证书检测 单元测试', () => {
|
|
const metadata = getMetadataArgsStorage();
|
|
|
|
// ==========================================
|
|
// 1. 实体定义与数据库字段映射测试
|
|
// ==========================================
|
|
describe('1. 实体定义与数据库字段映射测试', () => {
|
|
it('TeamDomainEntity 表名映射应为 team_domain 且继承 BaseEntity', () => {
|
|
const table = metadata.tables.find(t => t.target === TeamDomainEntity);
|
|
expect(table).toBeDefined();
|
|
expect(table?.name).toBe('team_domain');
|
|
expect(TeamDomainEntity.prototype instanceof BaseEntity).toBe(true);
|
|
});
|
|
|
|
it('TeamDomainEntity 应映射核心列名与类型约束', () => {
|
|
const columns = metadata.columns.filter(c => c.target === TeamDomainEntity);
|
|
const customDomainCol = columns.find(c => c.propertyName === 'customDomain');
|
|
const sslStatusCol = columns.find(c => c.propertyName === 'sslStatus');
|
|
const cnameTargetCol = columns.find(c => c.propertyName === 'cnameTarget');
|
|
const sslExpireTimeCol = columns.find(c => c.propertyName === 'sslExpireTime');
|
|
|
|
expect(customDomainCol).toBeDefined();
|
|
expect(customDomainCol?.options.name || customDomainCol?.propertyName).toBe('custom_domain');
|
|
|
|
expect(sslStatusCol).toBeDefined();
|
|
expect(sslStatusCol?.options.name || sslStatusCol?.propertyName).toBe('ssl_status');
|
|
|
|
expect(cnameTargetCol).toBeDefined();
|
|
expect(cnameTargetCol?.options.name || cnameTargetCol?.propertyName).toBe('cname_target');
|
|
|
|
expect(sslExpireTimeCol).toBeDefined();
|
|
expect(sslExpireTimeCol?.options.name || sslExpireTimeCol?.propertyName).toBe('ssl_expire_time');
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 2. 域名格式正则严格校验 (validateDomainFormat)
|
|
// ==========================================
|
|
describe('2. 域名格式正则严格校验 (validateDomainFormat)', () => {
|
|
let service: TeamDomainService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamDomainService();
|
|
});
|
|
|
|
it('合法企业独立二级与多级域名应通过校验', () => {
|
|
const validDomains = [
|
|
'card.mycompany.com',
|
|
'portal.omnicard.ai',
|
|
'sales.global-biz.co.uk',
|
|
'vip.vip-shop.cn',
|
|
'a.b.c.example.org',
|
|
];
|
|
|
|
for (const domain of validDomains) {
|
|
const result = service.validateDomainFormat(domain);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.reason).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('空值或纯空格域名应判定为非法', () => {
|
|
expect(service.validateDomainFormat('').valid).toBe(false);
|
|
expect(service.validateDomainFormat(' ').valid).toBe(false);
|
|
});
|
|
|
|
it('拦截包含端口号的域名', () => {
|
|
const result = service.validateDomainFormat('card.example.com:8080');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.reason).toContain('端口');
|
|
});
|
|
|
|
it('拦截带有 HTTP/HTTPS 协议前缀的输入', () => {
|
|
expect(service.validateDomainFormat('http://card.example.com').valid).toBe(false);
|
|
expect(service.validateDomainFormat('https://card.example.com').valid).toBe(false);
|
|
expect(service.validateDomainFormat('card.example.com/').valid).toBe(false);
|
|
});
|
|
|
|
it('拦截 IPv4 与 IPv6 地址输入', () => {
|
|
const ipList = ['192.168.1.1', '10.0.0.1', '127.0.0.1', '::1', '2001:db8::1'];
|
|
for (const ip of ipList) {
|
|
const result = service.validateDomainFormat(ip);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.reason).toMatch(/IP/i);
|
|
}
|
|
});
|
|
|
|
it('拦截内部保留域名及特定后缀', () => {
|
|
const reservedList = [
|
|
'localhost',
|
|
'card.localhost',
|
|
'service.local',
|
|
'api.internal',
|
|
'dev.lan',
|
|
'test.test',
|
|
'demo.example',
|
|
'mock.invalid',
|
|
];
|
|
for (const domain of reservedList) {
|
|
const result = service.validateDomainFormat(domain);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.reason).toContain('保留');
|
|
}
|
|
});
|
|
|
|
it('拦截含有下划线、空格或特殊字符的非法域名', () => {
|
|
const invalidChars = [
|
|
'card_domain.com',
|
|
'my brand.com',
|
|
'@portal.com',
|
|
'card#name.com',
|
|
'-bad.com',
|
|
'bad-.com',
|
|
'.bad.com',
|
|
'bad.com.',
|
|
];
|
|
for (const domain of invalidChars) {
|
|
const result = service.validateDomainFormat(domain);
|
|
expect(result.valid).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('拦截未达到二级域名的单层主机名', () => {
|
|
const result = service.validateDomainFormat('singlehostname');
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 3. DNS CNAME 记录核验 (verifyDnsRecord)
|
|
// ==========================================
|
|
describe('3. DNS CNAME 记录核验 (verifyDnsRecord)', () => {
|
|
let service: TeamDomainService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamDomainService();
|
|
});
|
|
|
|
it('当 CNAME 记录匹配预期 targetCname 时返回 verified=true (支持大小写与尾部点自动归一化)', async () => {
|
|
const res = await service.verifyDnsRecord('card.mycompany.com', 'cname.omnicard.ai', {
|
|
mockRecords: ['cname.omnicard.ai.'],
|
|
});
|
|
|
|
expect(res.verified).toBe(true);
|
|
expect(res.targetCname).toBe('cname.omnicard.ai');
|
|
expect(res.records).toEqual(['cname.omnicard.ai.']);
|
|
});
|
|
|
|
it('当解析结果为大写或混合且包含目标时匹配成功', async () => {
|
|
const res = await service.verifyDnsRecord('card.mycompany.com', 'CNAME.OMNICARD.AI', {
|
|
mockRecords: ['cname.omnicard.ai'],
|
|
});
|
|
|
|
expect(res.verified).toBe(true);
|
|
});
|
|
|
|
it('当 CNAME 记录不匹配时返回 verified=false', async () => {
|
|
const res = await service.verifyDnsRecord('card.mycompany.com', 'cname.omnicard.ai', {
|
|
mockRecords: ['wrong-cname.otherdomain.com'],
|
|
});
|
|
|
|
expect(res.verified).toBe(false);
|
|
expect(res.message).toContain('不匹配');
|
|
});
|
|
|
|
it('显式传入空模拟记录时 verified=false 且不假装已核验', async () => {
|
|
const res = await service.verifyDnsRecord('card.mycompany.com', 'cname.omnicard.ai', {
|
|
mockRecords: [],
|
|
});
|
|
|
|
expect(res.verified).toBe(false);
|
|
expect(res.records).toEqual([]);
|
|
expect(res.message).toMatch(/未找到/);
|
|
});
|
|
|
|
it('不传模拟记录且 DNS 解析失败时 verified 为 false', async () => {
|
|
const spy = jest
|
|
.spyOn(dns.promises, 'resolveCname')
|
|
.mockRejectedValue(
|
|
Object.assign(new Error('queryCname ENOTFOUND card.mycompany.com'), {
|
|
code: 'ENOTFOUND',
|
|
})
|
|
);
|
|
|
|
try {
|
|
const res = await service.verifyDnsRecord(
|
|
'card.mycompany.com',
|
|
'cname.omnicard.ai'
|
|
);
|
|
|
|
expect(spy).toHaveBeenCalledWith('card.mycompany.com');
|
|
expect(res.verified).toBe(false);
|
|
expect(res.records).toEqual([]);
|
|
expect(res.message).toMatch(/解析失败/);
|
|
expect(res.message).toMatch(/ENOTFOUND/);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 4. SSL 证书检测与过期时间计算 (checkSslCertificate)
|
|
// ==========================================
|
|
describe('4. SSL 证书状态与过期时间计算 (checkSslCertificate)', () => {
|
|
let service: TeamDomainService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamDomainService();
|
|
});
|
|
|
|
it('有效证书应计算准确的剩余天数并标记 status=active', async () => {
|
|
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
const pastDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
const mockCert: SslCertMockInfo = {
|
|
issuer: "Let's Encrypt Authority X3",
|
|
validFrom: pastDate,
|
|
validTo: futureDate,
|
|
authorized: true,
|
|
};
|
|
|
|
const res = await service.checkSslCertificate('card.mycompany.com', 443, { mockCert });
|
|
|
|
expect(res.valid).toBe(true);
|
|
expect(res.status).toBe('active');
|
|
expect(res.expired).toBe(false);
|
|
expect(res.daysRemaining).toBeGreaterThanOrEqual(29);
|
|
expect(res.issuer).toBe("Let's Encrypt Authority X3");
|
|
});
|
|
|
|
it('过期证书应标记 status=failed 且 expired=true', async () => {
|
|
const pastDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
|
const olderDate = new Date(Date.now() - 95 * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
const mockCert: SslCertMockInfo = {
|
|
issuer: 'DigiCert Global Root CA',
|
|
validFrom: olderDate,
|
|
validTo: pastDate,
|
|
authorized: true,
|
|
};
|
|
|
|
const res = await service.checkSslCertificate('card.mycompany.com', 443, { mockCert });
|
|
|
|
expect(res.valid).toBe(false);
|
|
expect(res.status).toBe('failed');
|
|
expect(res.expired).toBe(true);
|
|
expect(res.daysRemaining).toBeLessThanOrEqual(0);
|
|
expect(res.errorMessage).toContain('过期');
|
|
});
|
|
|
|
it('未经授权/未受信任证书应标记 status=failed', async () => {
|
|
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
const mockCert: SslCertMockInfo = {
|
|
issuer: 'Self-Signed Untrusted Root',
|
|
validFrom: new Date().toISOString(),
|
|
validTo: futureDate,
|
|
authorized: false,
|
|
};
|
|
|
|
const res = await service.checkSslCertificate('card.mycompany.com', 443, { mockCert });
|
|
|
|
expect(res.valid).toBe(false);
|
|
expect(res.status).toBe('failed');
|
|
expect(res.errorMessage).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 5. 绑定域名持久化业务逻辑 (bindDomain)
|
|
// ==========================================
|
|
describe('5. 绑定域名持久化业务逻辑 (bindDomain)', () => {
|
|
let service: TeamDomainService;
|
|
let domains: TeamDomainEntity[];
|
|
let nextId = 1;
|
|
|
|
beforeEach(() => {
|
|
domains = [];
|
|
nextId = 1;
|
|
|
|
// 预置已被租户 88 绑定的域名
|
|
const existing = new TeamDomainEntity();
|
|
existing.id = nextId++;
|
|
existing.tenantId = 88;
|
|
existing.customDomain = 'taken.brand.com';
|
|
existing.sslStatus = 'active';
|
|
domains.push(existing);
|
|
|
|
const mockRepo = {
|
|
findOne: jest.fn(async (options: { where: Partial<TeamDomainEntity> }) => {
|
|
return (
|
|
domains.find(d => {
|
|
if (options.where.customDomain && d.customDomain !== options.where.customDomain) {
|
|
return false;
|
|
}
|
|
if (options.where.tenantId && d.tenantId !== options.where.tenantId) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}) || null
|
|
);
|
|
}),
|
|
save: jest.fn(async (entity: TeamDomainEntity) => {
|
|
const idx = domains.findIndex(
|
|
d => (entity.id && d.id === entity.id) || d.customDomain === entity.customDomain
|
|
);
|
|
if (idx >= 0) {
|
|
domains[idx] = Object.assign(domains[idx], entity);
|
|
return domains[idx];
|
|
}
|
|
const saved = Object.assign(new TeamDomainEntity(), entity, {
|
|
id: nextId++,
|
|
createTime: new Date(),
|
|
updateTime: new Date(),
|
|
});
|
|
domains.push(saved);
|
|
return saved;
|
|
}),
|
|
};
|
|
|
|
service = new TeamDomainService();
|
|
service.teamDomainEntity = mockRepo as unknown as TeamDomainService['teamDomainEntity'];
|
|
});
|
|
|
|
it('缺少 tenantId 时抛出异常', async () => {
|
|
await expect(service.bindDomain(0, 'card.company.com')).rejects.toThrow(CoolCommException);
|
|
});
|
|
|
|
it('格式非法时阻断持久化并抛出异常', async () => {
|
|
await expect(service.bindDomain(1, 'http://bad-url.com:8080')).rejects.toThrow(
|
|
CoolCommException
|
|
);
|
|
});
|
|
|
|
it('域名已被其他租户占用时抛出防冲突异常', async () => {
|
|
await expect(service.bindDomain(99, 'taken.brand.com')).rejects.toThrow('已被其他企业绑定');
|
|
});
|
|
|
|
it('新租户首次绑定成功插入并保持 pending 状态', async () => {
|
|
const bound = await service.bindDomain(99, 'VIP.NewCompany.com', {
|
|
cnameTarget: 'cname.omnicard.ai',
|
|
});
|
|
|
|
expect(bound.id).toBeDefined();
|
|
expect(bound.tenantId).toBe(99);
|
|
expect(bound.customDomain).toBe('vip.newcompany.com');
|
|
expect(bound.sslStatus).toBe('pending');
|
|
expect(bound.cnameTarget).toBe('cname.omnicard.ai');
|
|
});
|
|
|
|
it('同一租户重复绑定或更新现有记录', async () => {
|
|
const bound1 = await service.bindDomain(88, 'taken.brand.com', {
|
|
cnameTarget: 'new-cname.omnicard.ai',
|
|
});
|
|
expect(bound1.id).toBe(1);
|
|
expect(bound1.cnameTarget).toBe('new-cname.omnicard.ai');
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 6. 控制器接口测试 (AdminTeamDomainController)
|
|
// ==========================================
|
|
describe('6. 控制器接口调用测试 (AdminTeamDomainController)', () => {
|
|
let controller: AdminTeamDomainController;
|
|
let service: TeamDomainService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamDomainService();
|
|
service.validateDomainFormat = jest.fn((domain: string) => ({
|
|
valid: domain === 'card.example.com',
|
|
reason: domain === 'card.example.com' ? undefined : '域名格式非法',
|
|
}));
|
|
|
|
service.verifyDnsRecord = jest.fn(async (domain: string, targetCname: string) => ({
|
|
verified: true,
|
|
records: [targetCname],
|
|
targetCname,
|
|
}));
|
|
|
|
service.checkSslCertificate = jest.fn(async () => ({
|
|
valid: true,
|
|
status: 'active' as const,
|
|
daysRemaining: 60,
|
|
}));
|
|
|
|
service.bindDomain = jest.fn(async (tenantId: number, domain: string) => {
|
|
const entity = new TeamDomainEntity();
|
|
entity.id = 1;
|
|
entity.tenantId = tenantId;
|
|
entity.customDomain = domain;
|
|
entity.sslStatus = 'pending';
|
|
return entity;
|
|
});
|
|
|
|
controller = new AdminTeamDomainController();
|
|
controller.teamDomainService = service;
|
|
controller.ok = (data: unknown) => ({
|
|
code: 1000,
|
|
message: 'success',
|
|
data,
|
|
});
|
|
(
|
|
controller as unknown as { ctx: { admin: { userId: number; tenantId?: number } } }
|
|
).ctx = {
|
|
admin: { userId: 1, tenantId: 10 },
|
|
};
|
|
});
|
|
|
|
type MockApiResponse<T> = { code: number; message: string; data: T };
|
|
|
|
it('POST /validate 返回校验结果', async () => {
|
|
const res = (await controller.validate({ domain: 'card.example.com' })) as unknown as MockApiResponse<{ valid: boolean }>;
|
|
expect(res.code).toBe(1000);
|
|
expect(res.data.valid).toBe(true);
|
|
});
|
|
|
|
it('POST /verify-dns 返回 DNS CNAME 核验结果', async () => {
|
|
const res = (await controller.verifyDns({
|
|
domain: 'card.example.com',
|
|
targetCname: 'cname.omnicard.ai',
|
|
})) as unknown as MockApiResponse<{ verified: boolean }>;
|
|
expect(res.code).toBe(1000);
|
|
expect(res.data.verified).toBe(true);
|
|
});
|
|
|
|
it('POST /check-ssl 返回 SSL 状态', async () => {
|
|
const res = (await controller.checkSsl({ domain: 'card.example.com' })) as unknown as MockApiResponse<{ status: string }>;
|
|
expect(res.code).toBe(1000);
|
|
expect(res.data.status).toBe('active');
|
|
});
|
|
|
|
it('POST /bind 绑定企业独立二级域名', async () => {
|
|
const res = await controller.bind({ domain: 'card.example.com' });
|
|
expect(res.code).toBe(1000);
|
|
expect(service.bindDomain).toHaveBeenCalledWith(10, 'card.example.com', expect.any(Object));
|
|
});
|
|
|
|
});
|
|
});
|