Initial LisgloSIPS V2 implementation
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuditAction } from '../audit/audit.metadata.js';
|
||||
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
|
||||
import { CustomerGatewaysService } from './customer-gateways.service.js';
|
||||
|
||||
@ApiTags('customer-gateways')
|
||||
@Controller('customer-gateways')
|
||||
export class CustomerGatewaysController {
|
||||
constructor(@Inject(CustomerGatewaysService) private readonly customerGatewaysService: CustomerGatewaysService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
list(@Query() query: unknown) {
|
||||
return this.customerGatewaysService.list(query as never);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('customer_gateways.view')
|
||||
get(@Param('id') id: string) {
|
||||
return this.customerGatewaysService.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'create', objectType: 'customer_gateway' })
|
||||
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.create(body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'update', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'enable', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('customer_gateways.manage')
|
||||
@AuditAction({ module: 'customer_gateways', action: 'disable', objectType: 'customer_gateway', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.customerGatewaysService.disable(id, currentUser?.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomerGatewaysController } from './customer-gateways.controller.js';
|
||||
import { CUSTOMER_GATEWAYS_REPOSITORY, PrismaCustomerGatewaysRepository } from './customer-gateways.repository.js';
|
||||
import { CustomerGatewaysService } from './customer-gateways.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomerGatewaysController],
|
||||
providers: [
|
||||
CustomerGatewaysService,
|
||||
{
|
||||
provide: CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
useClass: PrismaCustomerGatewaysRepository
|
||||
}
|
||||
],
|
||||
exports: [CustomerGatewaysService]
|
||||
})
|
||||
export class CustomerGatewaysModule {}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED';
|
||||
export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
|
||||
|
||||
export interface CustomerGatewaySummary {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
hasSipCredential: boolean;
|
||||
status: CustomerGatewayStatus;
|
||||
policyCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCustomerGatewayInput {
|
||||
customerId: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomerGatewayInput {
|
||||
customerId?: string;
|
||||
name?: string;
|
||||
authMode?: CustomerGatewayAuthMode;
|
||||
sourceIp?: string | null;
|
||||
sipUsername?: string | null;
|
||||
sipDomain?: string | null;
|
||||
sipHa1?: string | null;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface CustomerGatewaysRepository {
|
||||
list(customerId?: string): Promise<CustomerGatewaySummary[]>;
|
||||
get(gatewayId: string): Promise<CustomerGatewaySummary>;
|
||||
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
|
||||
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
|
||||
}
|
||||
|
||||
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
|
||||
|
||||
function gatewayId(): string {
|
||||
return `cgw_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function outboxId(): string {
|
||||
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(customerId?: string): Promise<CustomerGatewaySummary[]> {
|
||||
const gateways = await this.prisma.customerGateway.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
customerId
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
return gateways.map((gateway) => this.toSummary(gateway));
|
||||
}
|
||||
|
||||
async get(gatewayIdValue: string): Promise<CustomerGatewaySummary> {
|
||||
return this.toSummary(await this.findActiveOrThrow(gatewayIdValue));
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.customerGateway.create({
|
||||
data: {
|
||||
id: gatewayId(),
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
createdBy: input.actorId,
|
||||
updatedBy: input.actorId
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed');
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(gatewayIdValue: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
if (input.customerId) {
|
||||
await this.ensureCustomerExists(input.customerId);
|
||||
}
|
||||
|
||||
try {
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
name: input.name,
|
||||
authMode: input.authMode,
|
||||
sourceIp: input.sourceIp,
|
||||
sipUsername: input.sipUsername,
|
||||
sipDomain: input.sipDomain,
|
||||
sipHa1: input.sipHa1,
|
||||
updatedBy: input.actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
} catch (error) {
|
||||
this.handleUniqueConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setStatus(gatewayIdValue: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
await this.findActiveOrThrow(gatewayIdValue);
|
||||
|
||||
const gateway = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.customerGateway.update({
|
||||
where: { id: gatewayIdValue },
|
||||
data: {
|
||||
status,
|
||||
updatedBy: actorId,
|
||||
version: { increment: 1 }
|
||||
},
|
||||
include: this.includeSummary()
|
||||
});
|
||||
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
|
||||
return updated;
|
||||
});
|
||||
|
||||
return this.toSummary(gateway);
|
||||
}
|
||||
|
||||
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
id: outboxId(),
|
||||
aggregateType: 'customer_gateway_config',
|
||||
aggregateId,
|
||||
eventType,
|
||||
payload: {
|
||||
aggregateId,
|
||||
eventType
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCustomerExists(customerId: string): Promise<void> {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, deletedAt: true }
|
||||
});
|
||||
|
||||
if (!customer || customer.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_NOT_FOUND', message: 'Customer not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findActiveOrThrow(gatewayIdValue: string) {
|
||||
const gateway = await this.prisma.customerGateway.findUnique({
|
||||
where: { id: gatewayIdValue },
|
||||
include: this.includeSummary()
|
||||
});
|
||||
|
||||
if (!gateway || gateway.deletedAt) {
|
||||
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
|
||||
}
|
||||
|
||||
return gateway;
|
||||
}
|
||||
|
||||
private includeSummary() {
|
||||
return {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
policies: {
|
||||
where: { deletedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Prisma.CustomerGatewayInclude;
|
||||
}
|
||||
|
||||
private toSummary(gateway: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
authMode: CustomerGatewayAuthMode;
|
||||
sourceIp: string | null;
|
||||
sipUsername: string | null;
|
||||
sipDomain: string | null;
|
||||
sipHa1: string | null;
|
||||
status: CustomerGatewayStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
customer: { name: string };
|
||||
_count: { policies: number };
|
||||
}): CustomerGatewaySummary {
|
||||
return {
|
||||
id: gateway.id,
|
||||
customerId: gateway.customerId,
|
||||
customerName: gateway.customer.name,
|
||||
name: gateway.name,
|
||||
authMode: gateway.authMode,
|
||||
sourceIp: gateway.sourceIp,
|
||||
sipUsername: gateway.sipUsername,
|
||||
sipDomain: gateway.sipDomain,
|
||||
hasSipCredential: Boolean(gateway.sipHa1),
|
||||
status: gateway.status,
|
||||
policyCount: gateway._count.policies,
|
||||
createdAt: gateway.createdAt,
|
||||
updatedAt: gateway.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private handleUniqueConflict(error: unknown): void {
|
||||
if (error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'P2002') {
|
||||
throw new ConflictException({ code: 'CUSTOMER_GATEWAY_CONFLICT', message: 'Customer gateway name or SIP identity already exists.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
CUSTOMER_GATEWAYS_REPOSITORY,
|
||||
type CreateCustomerGatewayInput,
|
||||
type CustomerGatewayAuthMode,
|
||||
type CustomerGatewaySummary,
|
||||
type CustomerGatewaysRepository,
|
||||
type UpdateCustomerGatewayInput
|
||||
} from './customer-gateways.repository.js';
|
||||
|
||||
interface CreateCustomerGatewayDto {
|
||||
customerId?: unknown;
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
}
|
||||
|
||||
interface UpdateCustomerGatewayDto {
|
||||
customerId?: unknown;
|
||||
name?: unknown;
|
||||
authMode?: unknown;
|
||||
sourceIp?: unknown;
|
||||
sipUsername?: unknown;
|
||||
sipDomain?: unknown;
|
||||
sipPassword?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGatewaysService {
|
||||
constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {}
|
||||
|
||||
list(query: { customerId?: unknown } = {}): Promise<CustomerGatewaySummary[]> {
|
||||
const customerId = query.customerId === undefined ? undefined : this.limitedString(query.customerId, 'customerId', 32);
|
||||
return this.gateways.list(customerId);
|
||||
}
|
||||
|
||||
get(gatewayId: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.get(gatewayId);
|
||||
}
|
||||
|
||||
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const authMode = this.authMode(body.authMode);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
|
||||
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
|
||||
const input: CreateCustomerGatewayInput = {
|
||||
customerId: this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: this.normalizeSourceIp(authMode, body.sourceIp),
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined,
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.gateways.create(input);
|
||||
}
|
||||
|
||||
async update(gatewayId: string, body: UpdateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.gateways.get(gatewayId);
|
||||
const authMode = body.authMode === undefined ? current.authMode : this.authMode(body.authMode);
|
||||
const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120);
|
||||
const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain);
|
||||
const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45);
|
||||
const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp);
|
||||
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
|
||||
|
||||
if (this.requiresSip(authMode)) {
|
||||
const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain;
|
||||
if (!password && (identityChanged || !current.hasSipCredential)) {
|
||||
throw new BadRequestException({
|
||||
code: 'SIP_PASSWORD_REQUIRED',
|
||||
message: 'sipPassword is required when creating or changing SIP identity.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const input: UpdateCustomerGatewayInput = {
|
||||
customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32),
|
||||
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
|
||||
authMode,
|
||||
sourceIp: normalizedSourceIp,
|
||||
sipUsername: sipIdentity.sipUsername,
|
||||
sipDomain: sipIdentity.sipDomain,
|
||||
sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined,
|
||||
actorId
|
||||
};
|
||||
|
||||
return this.gateways.update(gatewayId, input);
|
||||
}
|
||||
|
||||
enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
private limitedString(value: unknown, field: string, maxLength: number): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private nullableString(value: unknown, field: string, maxLength: number): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private authMode(value: unknown): CustomerGatewayAuthMode {
|
||||
if (value !== 'IP' && value !== 'SIP_DIGEST' && value !== 'MIXED') {
|
||||
throw new BadRequestException({ code: 'AUTH_MODE_INVALID', message: 'Auth mode is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null {
|
||||
if (!this.requiresIp(authMode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceIp = this.limitedString(value, 'sourceIp', 45);
|
||||
if (net.isIP(sourceIp) === 0) {
|
||||
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' });
|
||||
}
|
||||
|
||||
return sourceIp;
|
||||
}
|
||||
|
||||
private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return {
|
||||
sipUsername: null,
|
||||
sipDomain: null
|
||||
};
|
||||
}
|
||||
|
||||
const sipUsername = this.limitedString(usernameValue, 'sipUsername', 120);
|
||||
const sipDomain = this.limitedString(domainValue, 'sipDomain', 160).toLowerCase();
|
||||
if (!/^[A-Za-z0-9_.:+-]+$/.test(sipUsername)) {
|
||||
throw new BadRequestException({ code: 'SIP_USERNAME_INVALID', message: 'sipUsername contains invalid characters.' });
|
||||
}
|
||||
if (!/^[A-Za-z0-9.-]+$/.test(sipDomain)) {
|
||||
throw new BadRequestException({ code: 'SIP_DOMAIN_INVALID', message: 'sipDomain contains invalid characters.' });
|
||||
}
|
||||
|
||||
return { sipUsername, sipDomain };
|
||||
}
|
||||
|
||||
private requiredSipPassword(authMode: CustomerGatewayAuthMode, value: unknown): string | undefined {
|
||||
if (!this.requiresSip(authMode)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const password = this.limitedString(value, 'sipPassword', 128);
|
||||
if (password.length < 12) {
|
||||
throw new BadRequestException({ code: 'SIP_PASSWORD_WEAK', message: 'sipPassword must be at least 12 characters.' });
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
private sipHa1(username: string | null, domain: string | null, password: string): string {
|
||||
if (!username || !domain) {
|
||||
throw new BadRequestException({ code: 'SIP_IDENTITY_REQUIRED', message: 'SIP identity is required.' });
|
||||
}
|
||||
|
||||
return crypto.createHash('md5').update(`${username}:${domain}:${password}`, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private requiresIp(authMode: CustomerGatewayAuthMode): boolean {
|
||||
return authMode === 'IP' || authMode === 'MIXED';
|
||||
}
|
||||
|
||||
private requiresSip(authMode: CustomerGatewayAuthMode): boolean {
|
||||
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user