fix web ui smoke and brand assets

This commit is contained in:
hectorzhao
2026-06-30 11:13:30 +08:00
parent 0dfb2988b2
commit 9920575bba
103 changed files with 6650 additions and 135 deletions
+4 -1
View File
@@ -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;