refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -3,7 +3,9 @@ import { decryptSecret, encryptSecret } from './open-api.crypto';
|
||||
import { OpenApiService } from './open-api.service';
|
||||
|
||||
describe('OpenApiService', () => {
|
||||
beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; });
|
||||
beforeAll(() => {
|
||||
process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters';
|
||||
});
|
||||
|
||||
it('encrypts secrets with authenticated encryption', () => {
|
||||
const encrypted = encryptSecret('customer-secret');
|
||||
@@ -15,11 +17,15 @@ describe('OpenApiService', () => {
|
||||
const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplication: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }));
|
||||
await expect(service.getConfig('app-1')).resolves.toEqual(
|
||||
expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }),
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||
else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
|
||||
@@ -32,13 +38,17 @@ describe('OpenApiService', () => {
|
||||
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
|
||||
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplication: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
|
||||
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true';
|
||||
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }));
|
||||
await expect(service.getConfig('app-1')).resolves.toEqual(
|
||||
expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }),
|
||||
);
|
||||
} finally {
|
||||
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
|
||||
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
|
||||
@@ -49,25 +59,62 @@ describe('OpenApiService', () => {
|
||||
|
||||
it('replays a completed request for the same idempotency key and body', async () => {
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
|
||||
openApiRequest: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
bodyHash: 'same',
|
||||
status: 'completed',
|
||||
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const sendChain = { createHttpBatchTask: jest.fn() };
|
||||
const service = new OpenApiService(prisma as never, sendChain as never);
|
||||
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' });
|
||||
const result = await service.sendMessage(
|
||||
auth() as never,
|
||||
{ mobile: '18821203795', content: '【测试】短信' },
|
||||
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
|
||||
);
|
||||
expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' });
|
||||
expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects reuse of an idempotency key with a different body', async () => {
|
||||
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } };
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
auth() as never,
|
||||
{ mobile: '18821203795', content: '【测试】短信' },
|
||||
{ idempotencyKey: 'idem-0001', bodyHash: 'new' },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('replays the same persisted business rejection', async () => {
|
||||
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } };
|
||||
const prisma = {
|
||||
openApiRequest: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
bodyHash: 'same',
|
||||
status: 'failed',
|
||||
httpStatus: 422,
|
||||
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 });
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
auth() as never,
|
||||
{ mobile: '18821203795', content: '【测试】短信' },
|
||||
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
|
||||
),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('uses the real send chain and persists the accepted response', async () => {
|
||||
@@ -79,47 +126,107 @@ describe('OpenApiService', () => {
|
||||
},
|
||||
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) };
|
||||
const sendChain = {
|
||||
createHttpBatchTask: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, sendChain as never);
|
||||
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' });
|
||||
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }));
|
||||
expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }));
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) }));
|
||||
const result = await service.sendMessage(
|
||||
auth() as never,
|
||||
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
|
||||
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
|
||||
);
|
||||
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
|
||||
);
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists a 422 result when the real send chain rejects the business request', async () => {
|
||||
const prisma = {
|
||||
openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) },
|
||||
openApiRequest: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never);
|
||||
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 });
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) }));
|
||||
const service = new OpenApiService(
|
||||
prisma as never,
|
||||
{ createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never,
|
||||
);
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
auth() as never,
|
||||
{ mobile: '18821203795', content: '未匹配模板' },
|
||||
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
|
||||
),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
|
||||
smsApplication: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
|
||||
}),
|
||||
},
|
||||
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
|
||||
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
|
||||
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
const input = { tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt' as const, messageRecordId: 'record-1', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } };
|
||||
const input = {
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
eventType: 'receipt' as const,
|
||||
messageRecordId: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
payload: { receiptStatus: 'delivered' },
|
||||
};
|
||||
await service.queueWebhookEvent(input);
|
||||
await service.queueWebhookEvent(input);
|
||||
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { eventId: 'evt_receipt_record-1' },
|
||||
}));
|
||||
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { eventId: 'evt_receipt_record-1' },
|
||||
}),
|
||||
);
|
||||
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
|
||||
}));
|
||||
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
|
||||
const prisma = {
|
||||
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) },
|
||||
smsApplication: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
name: '应用A',
|
||||
interfaceEnabled: true,
|
||||
httpConfig: null,
|
||||
httpIpAllowlist: [],
|
||||
}),
|
||||
},
|
||||
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
|
||||
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
@@ -128,19 +235,21 @@ describe('OpenApiService', () => {
|
||||
|
||||
await service.updateConfig('app-1', { enabled: true });
|
||||
|
||||
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'both',
|
||||
uplinkDeliveryMode: 'both',
|
||||
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
messageQueryEnabled: true,
|
||||
receiptWebhookEnabled: true,
|
||||
uplinkWebhookEnabled: true,
|
||||
uplinkQueryEnabled: true,
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'both',
|
||||
uplinkDeliveryMode: 'both',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a webhook endpoint when an operator saves a blank address', async () => {
|
||||
@@ -160,12 +269,35 @@ describe('OpenApiService', () => {
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
|
||||
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' }))
|
||||
.resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }));
|
||||
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })).resolves.toEqual(
|
||||
expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }),
|
||||
);
|
||||
expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', eventType: 'receipt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an already expired credential before writing a secret', async () => {
|
||||
const prisma = {
|
||||
smsApplication: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
name: '应用A',
|
||||
interfaceEnabled: true,
|
||||
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
|
||||
httpIpAllowlist: [],
|
||||
}),
|
||||
},
|
||||
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
|
||||
};
|
||||
const service = new OpenApiService(prisma as never, {} as never);
|
||||
await expect(
|
||||
service.createCredential('app-1', { expiresAt: '2020-01-01T00:00:00.000Z' }, 'tenant-1', true),
|
||||
).rejects.toThrow('凭据过期时间必须晚于当前时间');
|
||||
expect(prisma.httpApiCredential.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function auth() {
|
||||
|
||||
Reference in New Issue
Block a user