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 { CUSTOMER_GATEWAYS_REPOSITORY, type CreateCustomerGatewayInput, type CustomerGatewayStatus, type CustomerGatewaySummary, type CustomerGatewaysRepository, type UpdateCustomerGatewayInput } from './customer-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 MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository { private readonly gateways = new Map(); constructor() { this.gateways.set( 'cgw_seed', this.summary({ id: 'cgw_seed', customerId: 'cus_seed', name: 'Seed IP Gateway', authMode: 'IP', sourceIp: '100.93.185.30', hasSipCredential: false, policyCount: 1 }) ); } async list(customerId?: string): Promise { return [...this.gateways.values()].filter((gateway) => !customerId || gateway.customerId === customerId); } async get(gatewayId: string): Promise { return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' }); } async create(input: CreateCustomerGatewayInput): Promise { const gateway = this.summary({ id: 'cgw_created', customerId: input.customerId, name: input.name, authMode: input.authMode, sourceIp: input.sourceIp ?? null, sipUsername: input.sipUsername ?? null, sipDomain: input.sipDomain ?? null, hasSipCredential: Boolean(input.sipHa1) }); this.gateways.set(gateway.id, gateway); return gateway; } async update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise { const current = await this.get(gatewayId); const updated: CustomerGatewaySummary = { ...current, customerId: input.customerId ?? current.customerId, name: input.name ?? current.name, authMode: input.authMode ?? current.authMode, sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp, sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername, sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain, hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1), updatedAt: new Date('2026-06-21T03:00:00.000Z') }; this.gateways.set(gatewayId, updated); return updated; } async setStatus(gatewayId: string, status: CustomerGatewayStatus): Promise { const current = await this.get(gatewayId); const updated = { ...current, status }; this.gateways.set(gatewayId, updated); return updated; } private summary(input: { id: string; customerId?: string; name: string; authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED'; sourceIp?: string | null; sipUsername?: string | null; sipDomain?: string | null; hasSipCredential?: boolean; status?: CustomerGatewayStatus; policyCount?: number; }): CustomerGatewaySummary { return { id: input.id, customerId: input.customerId ?? 'cus_seed', customerName: 'Seed Customer', name: input.name, authMode: input.authMode ?? 'IP', sourceIp: input.sourceIp ?? null, sipUsername: input.sipUsername ?? null, sipDomain: input.sipDomain ?? null, hasSipCredential: input.hasSipCredential ?? false, status: input.status ?? 'ENABLED', policyCount: input.policyCount ?? 0, createdAt: new Date('2026-06-21T02:30:00.000Z'), updatedAt: new Date('2026-06-21T02:30:00.000Z') }; } } describe('S13 customer gateways API', () => { let app: NestFastifyApplication; let audit: MemoryAuditRepository; 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(); identities.users.set('usr_ops', { id: 'usr_ops', username: 'ops', roles: ['运营管理员'], permissions: ['customer_gateways.view', 'customer_gateways.manage'] as PermissionKey[] }); identities.users.set('usr_viewer', { id: 'usr_viewer', username: 'viewer', roles: ['只读'], permissions: ['customer_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(CUSTOMER_GATEWAYS_REPOSITORY) .useValue(new MemoryCustomerGatewaysRepository()) .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 gateways without exposing SIP secrets', async () => { const response = await request(app.getHttpServer()) .get('/api/v2/customer-gateways?customerId=cus_seed') .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) .expect(200); expect(response.body[0]).toMatchObject({ id: 'cgw_seed', authMode: 'IP', sourceIp: '100.93.185.30', policyCount: 1 }); expect(JSON.stringify(response.body)).not.toContain('sipPassword'); expect(JSON.stringify(response.body)).not.toContain('sipHa1'); }); it('rejects writes without customer_gateways.manage', async () => { await request(app.getHttpServer()) .post('/api/v2/customer-gateways') .set('Authorization', `Bearer ${tokenFor('usr_viewer')}`) .send({ customerId: 'cus_seed', name: 'Denied', authMode: 'IP', sourceIp: '100.93.185.31' }) .expect(403); }); it('creates SIP digest gateway, hides the password, and writes audit', async () => { const response = await request(app.getHttpServer()) .post('/api/v2/customer-gateways') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .send({ customerId: 'cus_seed', name: 'SIP Digest Gateway', authMode: 'SIP_DIGEST', sipUsername: 'alice-gw', sipDomain: 'SIP.EXAMPLE.LOCAL', sipPassword: 'change-me-very-strong' }) .expect(201); expect(response.body).toMatchObject({ id: 'cgw_created', authMode: 'SIP_DIGEST', sipUsername: 'alice-gw', sipDomain: 'sip.example.local', sourceIp: null, hasSipCredential: true }); expect(response.body.sipPassword).toBeUndefined(); expect(response.body.sipHa1).toBeUndefined(); expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true); expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong'); }); it('requires a new SIP password when SIP identity changes', async () => { await request(app.getHttpServer()) .patch('/api/v2/customer-gateways/cgw_created') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .send({ sipDomain: 'new.example.local' }) .expect(400); }); it('switches to IP auth and supports enable/disable', async () => { await request(app.getHttpServer()) .patch('/api/v2/customer-gateways/cgw_created') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .send({ authMode: 'IP', sourceIp: '100.93.185.32' }) .expect(200) .expect((response) => { expect(response.body).toMatchObject({ authMode: 'IP', sourceIp: '100.93.185.32', sipUsername: null, sipDomain: null, hasSipCredential: false }); }); await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201); }); });