fix web ui smoke and brand assets
This commit is contained in:
@@ -11,7 +11,10 @@ async function bootstrap(): Promise<void> {
|
||||
AppModule,
|
||||
new FastifyAdapter({
|
||||
trustProxy: true,
|
||||
logger: false
|
||||
logger: false,
|
||||
routerOptions: {
|
||||
maxParamLength: 256
|
||||
}
|
||||
}),
|
||||
{
|
||||
bufferLogs: true
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } 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 { OpenSipsMiClient } from './opensips-mi.client.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 {
|
||||
async write(_input: AuditEntryInput): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('active calls API', () => {
|
||||
let app: NestFastifyApplication;
|
||||
|
||||
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();
|
||||
identities.users.set('usr_active', {
|
||||
id: 'usr_active',
|
||||
username: 'active',
|
||||
roles: ['话务'],
|
||||
permissions: ['active_calls.view', 'active_calls.manage'] 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(new MemoryAuditRepository())
|
||||
.overrideProvider(OpenSipsMiClient)
|
||||
.useValue({ endDialog: vi.fn(), listDialogs: vi.fn() })
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false, routerOptions: { maxParamLength: 256 } }));
|
||||
app.setGlobalPrefix('api/v2');
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('returns ACTIVE_CALL_ID_INVALID for long invalid dialog ids before MI calls', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v2/active-calls/${'x'.repeat(129)}/hangup`)
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_active')}`)
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('ACTIVE_CALL_ID_INVALID');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -120,5 +120,7 @@ describe('active calls service', () => {
|
||||
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
|
||||
|
||||
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.hangup('x'.repeat(129))).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.hangup('x'.repeat(20))).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface ActiveCallSummary {
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
|
||||
const SAFE_DIALOG_ID = /^(?=.{1,128}$)(?=.*[@.:])[A-Za-z0-9@._:%+\-=]+$/;
|
||||
|
||||
@Injectable()
|
||||
export class ActiveCallsService {
|
||||
|
||||
@@ -198,6 +198,32 @@ describe('S12 recharges API', () => {
|
||||
expect(audit.entries.some((entry) => entry.module === 'recharges' && entry.action === 'customer_recharge' && entry.result === 'SUCCESS')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates negative customer recharge as a balance deduction', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customers/cus_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send({ amount: '-3.25', idempotencyKey: 'customer-deduct-001', remark: 'manual deduction' })
|
||||
.expect(201)
|
||||
.expect((response) => {
|
||||
expect(response.body).toMatchObject({
|
||||
accountType: 'CUSTOMER',
|
||||
accountId: 'cus_seed',
|
||||
amount: '-3.250000',
|
||||
beforeBalance: '25.250000',
|
||||
afterBalance: '22.000000'
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customers/cus_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send({ amount: '-0.000000', idempotencyKey: 'customer-deduct-zero' })
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('MONEY_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates vendor recharge, lists ledgers, and rejects idempotency conflicts', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors/ven_seed/recharges')
|
||||
@@ -219,9 +245,18 @@ describe('S12 recharges API', () => {
|
||||
.send({ amount: '4.5', idempotencyKey: 'vendor-rch-001' })
|
||||
.expect(409);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors/ven_seed/recharges')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
|
||||
.send({ amount: '-1', idempotencyKey: 'vendor-negative-001' })
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('MONEY_INVALID');
|
||||
});
|
||||
|
||||
const list = await request(app.getHttpServer()).get('/api/v2/recharges?take=10').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200);
|
||||
|
||||
expect(list.body.total).toBe(2);
|
||||
expect(list.body.total).toBe(3);
|
||||
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'CUSTOMER')).toBe(true);
|
||||
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'VENDOR')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export class RechargesService {
|
||||
}
|
||||
|
||||
rechargeCustomer(customerId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
|
||||
const amount = this.money(body.amount, 'amount');
|
||||
const amount = this.signedMoney(body.amount, 'amount');
|
||||
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
|
||||
const remark = this.optionalString(body.remark, 'remark', 500);
|
||||
|
||||
@@ -50,7 +50,7 @@ export class RechargesService {
|
||||
}
|
||||
|
||||
rechargeVendor(vendorId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
|
||||
const amount = this.money(body.amount, 'amount');
|
||||
const amount = this.positiveMoney(body.amount, 'amount');
|
||||
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
|
||||
const remark = this.optionalString(body.remark, 'remark', 500);
|
||||
|
||||
@@ -105,7 +105,7 @@ export class RechargesService {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
private positiveMoney(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a positive decimal with up to 6 places.` });
|
||||
@@ -120,6 +120,23 @@ export class RechargesService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private signedMoney(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^-?(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-zero signed decimal with up to 6 places.` });
|
||||
}
|
||||
|
||||
const negative = raw.startsWith('-');
|
||||
const unsigned = negative ? raw.slice(1) : raw;
|
||||
const [integerPart, fractionPart = ''] = unsigned.split('.');
|
||||
const normalized = `${negative ? '-' : ''}${integerPart}.${fractionPart.padEnd(6, '0')}`;
|
||||
if (normalized === '0.000000' || normalized === '-0.000000') {
|
||||
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must not be zero.` });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private pageNumber(value: unknown, defaultValue: number, max: number): number {
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
|
||||
Reference in New Issue
Block a user