Files
lisglosips/apps/api/src/modules/customer-gateways/customer-gateways.e2e.spec.ts
T

274 lines
9.6 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 {
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<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 MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
private readonly gateways = new Map<string, CustomerGatewaySummary>();
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<CustomerGatewaySummary[]> {
return [...this.gateways.values()].filter((gateway) => !customerId || gateway.customerId === customerId);
}
async get(gatewayId: string): Promise<CustomerGatewaySummary> {
return this.gateways.get(gatewayId) ?? this.summary({ id: gatewayId, name: 'Missing Gateway' });
}
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
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<CustomerGatewaySummary> {
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<CustomerGatewaySummary> {
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<NestFastifyApplication>(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);
});
});