350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import 'reflect-metadata';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { Test, type TestingModule } from '@nestjs/testing';
|
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import request from 'supertest';
|
|
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
|
|
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
|
|
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
|
|
import type { CurrentUser } from '../security/security.metadata.js';
|
|
import {
|
|
VENDOR_GATEWAYS_REPOSITORY,
|
|
type CreateVendorGatewayInput,
|
|
type UpdateVendorGatewayInput,
|
|
type VendorGatewayStatus,
|
|
type VendorGatewaySummary,
|
|
type VendorGatewaysRepository
|
|
} from './vendor-gateways.repository.js';
|
|
|
|
class MemoryIdentityRepository implements IdentityRepository {
|
|
users = new Map<string, CurrentUser>();
|
|
|
|
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
|
|
return this.users.get(userId) ?? null;
|
|
}
|
|
}
|
|
|
|
class MemoryAuditRepository implements AuditRepository {
|
|
entries: AuditEntryInput[] = [];
|
|
|
|
async write(input: AuditEntryInput): Promise<void> {
|
|
this.entries.push(input);
|
|
}
|
|
}
|
|
|
|
class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
|
private readonly gateways = new Map<string, VendorGatewaySummary>();
|
|
outboxEvents = 0;
|
|
|
|
constructor() {
|
|
this.gateways.set(
|
|
'vgw_seed',
|
|
this.summary({
|
|
id: 'vgw_seed',
|
|
vendorId: 'ven_seed',
|
|
name: 'Seed Gateway',
|
|
host: 'carrier.example.local',
|
|
cpsLimit: 20,
|
|
concurrencyLimit: 200,
|
|
cycleRate: '0.120000',
|
|
billingCycleSec: 60,
|
|
landingCalleePrefix: '86',
|
|
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
|
|
})
|
|
);
|
|
}
|
|
|
|
async list(vendorId?: string): Promise<VendorGatewaySummary[]> {
|
|
return [...this.gateways.values()].filter((gateway) => !vendorId || gateway.vendorId === vendorId);
|
|
}
|
|
|
|
async get(gatewayId: string): Promise<VendorGatewaySummary> {
|
|
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
|
|
}
|
|
|
|
async create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
|
const gateway = this.summary({
|
|
id: 'vgw_created',
|
|
vendorId: input.vendorId,
|
|
name: input.name,
|
|
authMode: input.authMode,
|
|
host: input.host,
|
|
port: input.port,
|
|
transport: input.transport,
|
|
sipUsername: input.sipUsername ?? null,
|
|
hasSipCredential: Boolean(input.sipHa1),
|
|
cpsLimit: input.cpsLimit,
|
|
concurrencyLimit: input.concurrencyLimit,
|
|
billingCycleSec: input.billingCycleSec,
|
|
cycleRate: input.cycleRate,
|
|
landingCalleePrefix: input.landingCalleePrefix ?? null,
|
|
callerRewritePool: input.callerRewritePool.map((item, index) => ({ id: `caller_${index}`, ...item })),
|
|
forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })),
|
|
codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })),
|
|
prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule }))
|
|
});
|
|
this.gateways.set(gateway.id, gateway);
|
|
this.outboxEvents += 1;
|
|
return gateway;
|
|
}
|
|
|
|
async update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary> {
|
|
const current = await this.get(gatewayId);
|
|
const updated = this.summary({
|
|
...current,
|
|
vendorId: input.vendorId ?? current.vendorId,
|
|
name: input.name ?? current.name,
|
|
authMode: input.authMode ?? current.authMode,
|
|
host: input.host ?? current.host,
|
|
port: input.port ?? current.port,
|
|
transport: input.transport ?? current.transport,
|
|
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
|
|
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
|
|
cpsLimit: input.cpsLimit ?? current.cpsLimit,
|
|
concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit,
|
|
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
|
|
cycleRate: input.cycleRate ?? current.cycleRate,
|
|
landingCalleePrefix: input.landingCalleePrefix === undefined ? current.landingCalleePrefix : input.landingCalleePrefix,
|
|
callerRewritePool:
|
|
input.callerRewritePool === undefined ? current.callerRewritePool : input.callerRewritePool.map((item, index) => ({ id: `caller_updated_${index}`, ...item })),
|
|
status: input.status ?? current.status,
|
|
forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })),
|
|
codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })),
|
|
prefixRules: input.prefixRules === undefined ? current.prefixRules : input.prefixRules.map((rule, index) => ({ id: `rule_updated_${index}`, ...rule }))
|
|
});
|
|
this.gateways.set(gatewayId, updated);
|
|
this.outboxEvents += 1;
|
|
return updated;
|
|
}
|
|
|
|
async setStatus(gatewayId: string, status: VendorGatewayStatus): Promise<VendorGatewaySummary> {
|
|
return this.update(gatewayId, { status });
|
|
}
|
|
|
|
async softDelete(gatewayId: string): Promise<VendorGatewaySummary> {
|
|
const current = await this.get(gatewayId);
|
|
const deleted = this.summary({ ...current, status: 'DISABLED' });
|
|
this.gateways.delete(gatewayId);
|
|
this.outboxEvents += 1;
|
|
return deleted;
|
|
}
|
|
|
|
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
|
|
const cycleRate = input.cycleRate ?? '0.000000';
|
|
const billingCycleSec = input.billingCycleSec ?? 60;
|
|
return {
|
|
id: input.id,
|
|
vendorId: input.vendorId ?? 'ven_seed',
|
|
vendorName: input.vendorName ?? 'Seed Vendor',
|
|
name: input.name,
|
|
authMode: input.authMode ?? 'IP',
|
|
host: input.host ?? 'carrier.example.local',
|
|
port: input.port ?? 5060,
|
|
transport: input.transport ?? 'udp',
|
|
sipUsername: input.sipUsername ?? null,
|
|
hasSipCredential: input.hasSipCredential ?? false,
|
|
cpsLimit: input.cpsLimit ?? 0,
|
|
concurrencyLimit: input.concurrencyLimit ?? 0,
|
|
billingCycleSec,
|
|
cycleRate,
|
|
minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6),
|
|
landingCalleePrefix: input.landingCalleePrefix ?? null,
|
|
callerRewritePool: input.callerRewritePool ?? [],
|
|
status: input.status ?? 'ENABLED',
|
|
forbiddenPeriods: input.forbiddenPeriods ?? [],
|
|
codecs: input.codecs ?? [],
|
|
prefixRules: input.prefixRules ?? [],
|
|
createdAt: new Date('2026-06-21T06:00:00.000Z'),
|
|
updatedAt: new Date('2026-06-21T06:00:00.000Z')
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('S16 vendor gateways API', () => {
|
|
let app: NestFastifyApplication;
|
|
let audit: MemoryAuditRepository;
|
|
let repository: MemoryVendorGatewaysRepository;
|
|
|
|
const tokenFor = (userId: string) =>
|
|
signAccessToken(
|
|
{
|
|
sub: userId,
|
|
username: userId,
|
|
roles: ['test'],
|
|
typ: 'access'
|
|
},
|
|
{
|
|
secret: 'test-only-access-token-secret-min-32-bytes',
|
|
issuer: 'lisglosips-api',
|
|
audience: 'lisglosips-web',
|
|
ttlSeconds: 900
|
|
}
|
|
);
|
|
|
|
beforeAll(async () => {
|
|
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
|
|
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
|
|
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
|
|
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
|
|
|
|
const identities = new MemoryIdentityRepository();
|
|
audit = new MemoryAuditRepository();
|
|
repository = new MemoryVendorGatewaysRepository();
|
|
|
|
identities.users.set('usr_ops', {
|
|
id: 'usr_ops',
|
|
username: 'ops',
|
|
roles: ['运营管理员'],
|
|
permissions: ['vendor_gateways.view', 'vendor_gateways.manage'] as PermissionKey[]
|
|
});
|
|
identities.users.set('usr_viewer', {
|
|
id: 'usr_viewer',
|
|
username: 'viewer',
|
|
roles: ['只读'],
|
|
permissions: ['vendor_gateways.view'] as PermissionKey[]
|
|
});
|
|
|
|
const { AppModule } = await import('../app.module.js');
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] })
|
|
.overrideProvider(IDENTITY_REPOSITORY)
|
|
.useValue(identities)
|
|
.overrideProvider(AUDIT_REPOSITORY)
|
|
.useValue(audit)
|
|
.overrideProvider(VENDOR_GATEWAYS_REPOSITORY)
|
|
.useValue(repository)
|
|
.compile();
|
|
|
|
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
|
|
app.setGlobalPrefix('api/v2');
|
|
await app.init();
|
|
await app.getHttpAdapter().getInstance().ready();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
});
|
|
|
|
it('lists vendor gateways with limits and derived minute rate', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/api/v2/vendor-gateways?vendorId=ven_seed')
|
|
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
|
.expect(200);
|
|
|
|
expect(response.body[0]).toMatchObject({
|
|
id: 'vgw_seed',
|
|
cpsLimit: 20,
|
|
concurrencyLimit: 200,
|
|
cycleRate: '0.120000',
|
|
minuteRate: '0.120000',
|
|
landingCalleePrefix: '86',
|
|
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
|
|
});
|
|
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
|
|
});
|
|
|
|
it('rejects writes without vendor_gateways.manage', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v2/vendor-gateways')
|
|
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
|
|
.send({ vendorId: 'ven_seed', name: 'Denied', authMode: 'IP', host: '1.2.3.4' })
|
|
.expect(403);
|
|
});
|
|
|
|
it('creates and updates a full vendor gateway configuration', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v2/vendor-gateways')
|
|
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
|
.send({
|
|
vendorId: 'ven_seed',
|
|
name: 'Carrier SIP',
|
|
authMode: 'SIP_DIGEST',
|
|
host: 'SIP.CARRIER.LOCAL',
|
|
port: 5060,
|
|
transport: 'udp',
|
|
sipUsername: 'carrier-user',
|
|
sipPassword: 'change-me-very-strong',
|
|
cpsLimit: 30,
|
|
concurrencyLimit: 300,
|
|
billingCycleSec: 6,
|
|
cycleRate: '0.012',
|
|
landingCalleePrefix: '86',
|
|
callerRewritePool: [
|
|
{ caller: '02160010001', weight: 80 },
|
|
{ caller: '02160010002', weight: 20 }
|
|
],
|
|
forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }],
|
|
codecs: [
|
|
{ codec: 'PCMA', priority: 1 },
|
|
{ codec: 'PCMU', priority: 2 }
|
|
],
|
|
prefixRules: [{ direction: 'CALLEE', matchPrefix: '00', replacePrefix: '+', priority: 1 }]
|
|
})
|
|
.expect(201)
|
|
.expect((response) => {
|
|
expect(response.body).toMatchObject({
|
|
id: 'vgw_created',
|
|
authMode: 'SIP_DIGEST',
|
|
host: 'sip.carrier.local',
|
|
hasSipCredential: true,
|
|
billingCycleSec: 6,
|
|
cycleRate: '0.012000',
|
|
minuteRate: '0.120000',
|
|
landingCalleePrefix: '86'
|
|
});
|
|
expect(response.body.callerRewritePool).toHaveLength(2);
|
|
expect(response.body.sipHa1).toBeUndefined();
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.patch('/api/v2/vendor-gateways/vgw_created')
|
|
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
|
.send({
|
|
authMode: 'IP',
|
|
host: '203.0.113.10',
|
|
landingCalleePrefix: 'ABC',
|
|
callerRewritePool: [{ caller: '02170020001', weight: 100 }],
|
|
codecs: [{ codec: 'G729', priority: 1 }],
|
|
prefixRules: []
|
|
})
|
|
.expect(200)
|
|
.expect((response) => {
|
|
expect(response.body).toMatchObject({
|
|
authMode: 'IP',
|
|
host: '203.0.113.10',
|
|
sipUsername: null,
|
|
hasSipCredential: false
|
|
});
|
|
expect(response.body.codecs).toHaveLength(1);
|
|
expect(response.body.prefixRules).toHaveLength(0);
|
|
expect(response.body).toMatchObject({ landingCalleePrefix: 'ABC' });
|
|
expect(response.body.callerRewritePool[0]).toMatchObject({ caller: '02170020001', weight: 100 });
|
|
});
|
|
|
|
expect(repository.outboxEvents).toBeGreaterThanOrEqual(2);
|
|
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'create')).toBe(true);
|
|
expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong');
|
|
});
|
|
|
|
it('validates child configuration and supports enable/disable', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/api/v2/vendor-gateways')
|
|
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
|
.send({
|
|
vendorId: 'ven_seed',
|
|
name: 'Invalid Codec',
|
|
authMode: 'IP',
|
|
host: '203.0.113.20',
|
|
codecs: [
|
|
{ codec: 'PCMA', priority: 1 },
|
|
{ codec: 'PCMU', priority: 1 }
|
|
]
|
|
})
|
|
.expect(400);
|
|
|
|
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
|
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
|
await request(app.getHttpServer()).delete('/api/v2/vendor-gateways/vgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
|
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
|
|
});
|
|
});
|