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(); async findCurrentUserById(userId: string): Promise { return this.users.get(userId) ?? null; } } class MemoryAuditRepository implements AuditRepository { entries: AuditEntryInput[] = []; async write(input: AuditEntryInput): Promise { this.entries.push(input); } } class MemoryVendorGatewaysRepository implements VendorGatewaysRepository { private readonly gateways = new Map(); 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 { return [...this.gateways.values()].filter((gateway) => !vendorId || gateway.vendorId === vendorId); } async get(gatewayId: string): Promise { return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' }); } async create(input: CreateVendorGatewayInput): Promise { 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 { 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 { return this.update(gatewayId, { status }); } async softDelete(gatewayId: string): Promise { 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 & { 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(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); }); });