fix phase2 gateway matching and release artifact

This commit is contained in:
hectorzhao
2026-06-28 22:16:49 +08:00
parent a86de6545f
commit 175166a5dc
17 changed files with 2670 additions and 35 deletions
@@ -275,6 +275,46 @@ describe('S13 customer gateways API', () => {
.expect(403);
});
it('rejects ambiguous source IP and business prefix matches', async () => {
await request(app.getHttpServer())
.post('/api/v2/customer-gateways')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({
customerId: 'cus_seed',
name: 'Duplicated Business Prefix Gateway',
authMode: 'IP',
sourceIps: ['100.93.185.30'],
lineGroupId: 'llg_seed',
callerMatchMode: 'ANY',
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: ['bp_seed']
})
.expect(409)
.expect((response) => {
expect(response.body.code).toBe('CUSTOMER_GATEWAY_MATCH_CONFLICT');
});
});
it('rejects overlapping caller prefixes on the same source IP', async () => {
await request(app.getHttpServer())
.post('/api/v2/customer-gateways')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({
customerId: 'cus_seed',
name: 'Overlapped Caller Prefix Gateway',
authMode: 'IP',
sourceIps: ['100.93.185.30'],
lineGroupId: 'llg_seed',
callerMatchMode: 'PREFIXES',
callerPrefixes: ['0211'],
calleeMatchMode: 'ANY'
})
.expect(409)
.expect((response) => {
expect(response.body.code).toBe('CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP');
});
});
it('creates SIP digest gateway, hides the password, and writes audit', async () => {
const response = await request(app.getHttpServer())
.post('/api/v2/customer-gateways')
@@ -1,4 +1,4 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import net from 'node:net';
import {
@@ -48,6 +48,17 @@ interface UpdateCustomerGatewayDto {
businessPrefixIds?: unknown;
}
interface GatewayMatchingConfig {
id?: string;
sourceIps: string[];
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: string[];
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixIds: string[];
}
const EMPTY_BUSINESS_PREFIX_KEY = '__EMPTY_BUSINESS_PREFIX__';
@Injectable()
export class CustomerGatewaysService {
constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {}
@@ -61,7 +72,7 @@ export class CustomerGatewaysService {
return this.gateways.get(gatewayId);
}
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
async 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);
@@ -88,6 +99,13 @@ export class CustomerGatewaysService {
actorId
};
await this.assertMatchingUniqueness(undefined, {
sourceIps,
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds
});
return this.gateways.create(input);
}
@@ -140,10 +158,20 @@ export class CustomerGatewaysService {
actorId
};
await this.assertMatchingUniqueness(gatewayId, {
id: gatewayId,
sourceIps,
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds
});
return this.gateways.update(gatewayId, input);
}
enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
async enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
const current = await this.gateways.get(gatewayId);
await this.assertMatchingUniqueness(gatewayId, this.summaryToMatchingConfig(current));
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
}
@@ -282,6 +310,15 @@ export class CustomerGatewaysService {
throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' });
}
}
const overlappedPrefix = prefixes.find((prefix, index) =>
prefixes.slice(index + 1).some((otherPrefix) => this.prefixesOverlap(prefix, otherPrefix))
);
if (overlappedPrefix) {
throw new BadRequestException({
code: 'CALLER_PREFIX_OVERLAP',
message: `Caller prefix "${overlappedPrefix}" overlaps with another caller prefix.`
});
}
return prefixes;
}
@@ -296,6 +333,71 @@ export class CustomerGatewaysService {
return ids;
}
private async assertMatchingUniqueness(ignoreGatewayId: string | undefined, proposed: GatewayMatchingConfig): Promise<void> {
if (proposed.sourceIps.length === 0) {
return;
}
const proposedIpSet = new Set(proposed.sourceIps);
const proposedBusinessKeys = this.businessPrefixKeys(proposed);
const proposedCallerPrefixes = proposed.callerMatchMode === 'PREFIXES' ? proposed.callerPrefixes : [];
const gateways = await this.gateways.list();
for (const gateway of gateways) {
if (gateway.id === ignoreGatewayId || gateway.status !== 'ENABLED') {
continue;
}
const sharedIps = gateway.sourceIps.filter((sourceIp) => proposedIpSet.has(sourceIp));
if (sharedIps.length === 0) {
continue;
}
const existingBusinessKeys = this.businessPrefixKeys(this.summaryToMatchingConfig(gateway));
const duplicatedBusinessKey = proposedBusinessKeys.find((key) => existingBusinessKeys.includes(key));
if (duplicatedBusinessKey) {
throw new ConflictException({
code: 'CUSTOMER_GATEWAY_MATCH_CONFLICT',
message:
duplicatedBusinessKey === EMPTY_BUSINESS_PREFIX_KEY
? 'The same source IP can have only one empty business-prefix fallback gateway.'
: 'The same source IP and business prefix can resolve to only one customer gateway.'
});
}
if (gateway.callerMatchMode === 'PREFIXES' && proposedCallerPrefixes.length > 0) {
const overlappedPrefix = proposedCallerPrefixes.find((prefix) =>
gateway.callerPrefixes.some((existingPrefix) => this.prefixesOverlap(prefix, existingPrefix))
);
if (overlappedPrefix) {
throw new ConflictException({
code: 'CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP',
message: `Caller prefix "${overlappedPrefix}" overlaps with another gateway on the same source IP.`
});
}
}
}
}
private summaryToMatchingConfig(gateway: CustomerGatewaySummary): GatewayMatchingConfig {
return {
id: gateway.id,
sourceIps: gateway.sourceIps,
callerMatchMode: gateway.callerMatchMode,
callerPrefixes: gateway.callerPrefixes,
calleeMatchMode: gateway.calleeMatchMode,
businessPrefixIds: gateway.businessPrefixes.map((item) => item.id)
};
}
private businessPrefixKeys(config: GatewayMatchingConfig): string[] {
return config.calleeMatchMode === 'BUSINESS_PREFIXES' ? config.businessPrefixIds : [EMPTY_BUSINESS_PREFIX_KEY];
}
private prefixesOverlap(left: string, right: string): boolean {
return left.startsWith(right) || right.startsWith(left);
}
private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,\s]+/) : [];
const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];