kt-template-online-api/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts

957 lines
30 KiB
TypeScript

import {
GUARDS_METADATA,
INTERCEPTORS_METADATA,
PIPES_METADATA,
} from '@nestjs/common/constants';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { PinoLogger } from 'nestjs-pino';
import { ApiExceptionFilter } from '../../../../src/common/filters/api-exception.filter';
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service';
import { QqbotMessageSubscriptionService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service';
import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service';
import { QqbotMessageTemplateService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-template.service';
import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller';
import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller';
import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard';
import { QqbotMessagePushContractErrorInterceptor } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-contract-error.interceptor';
import { QQBOT_MESSAGE_PUSH_PERMISSION } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator';
import { SystemMessageContractError } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import {
collectControllerRoutes,
routeKey,
} from '../../../helpers/controller-route.helper';
const SOURCE_READ_PERMISSIONS = [
'QqBot:MessageSubscription:List',
'QqBot:MessageSubscription:Create',
'QqBot:MessageSubscription:Update',
'QqBot:MessageTemplate:List',
'QqBot:MessageTemplate:Create',
'QqBot:MessageTemplate:Update',
'QqBot:MessageTemplate:Preview',
'QqBot:Account:MessagePush:List',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
];
const EXPECTED_ROUTE_PERMISSIONS: Record<string, string[]> = {
'DELETE /qqbot/accounts/:selfId/message-push/bindings/:id': [
'QqBot:Account:MessagePush:Delete',
],
'DELETE /qqbot/message-push/subscriptions/:id': [
'QqBot:MessageSubscription:Delete',
],
'DELETE /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Delete'],
'GET /qqbot/accounts/:selfId/message-push/bindings': [
'QqBot:Account:MessagePush:List',
],
'GET /qqbot/accounts/:selfId/message-push/targets': [
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
],
'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS,
'GET /qqbot/message-push/sources/:sourceKey': SOURCE_READ_PERMISSIONS,
'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options': [
'QqBot:MessageSubscription:Create',
'QqBot:MessageSubscription:Update',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
],
'GET /qqbot/message-push/subscriptions': [
'QqBot:MessageSubscription:List',
'QqBot:Account:MessagePush:List',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
],
'GET /qqbot/message-push/templates': [
'QqBot:MessageTemplate:List',
'QqBot:Account:MessagePush:List',
'QqBot:Account:MessagePush:Create',
'QqBot:Account:MessagePush:Update',
],
'POST /qqbot/accounts/:selfId/message-push/bindings': [
'QqBot:Account:MessagePush:Create',
],
'POST /qqbot/message-push/subscriptions': [
'QqBot:MessageSubscription:Create',
],
'POST /qqbot/message-push/templates': ['QqBot:MessageTemplate:Create'],
'POST /qqbot/message-push/templates/preview': [
'QqBot:MessageTemplate:Preview',
],
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id': [
'QqBot:Account:MessagePush:Update',
],
'PUT /qqbot/accounts/:selfId/message-push/bindings/:id/enabled': [
'QqBot:Account:MessagePush:Toggle',
],
'PUT /qqbot/message-push/subscriptions/:id': [
'QqBot:MessageSubscription:Update',
],
'PUT /qqbot/message-push/subscriptions/:id/enabled': [
'QqBot:MessageSubscription:Toggle',
],
'PUT /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Update'],
'PUT /qqbot/message-push/templates/:id/enabled': [
'QqBot:MessageTemplate:Toggle',
],
};
const STRING_ID = '123456789012345678901234';
const SELF_ID = '12345';
const pinoLogger = {
error: jest.fn(),
setContext: jest.fn(),
warn: jest.fn(),
};
/** Builds one valid strict STUN subscription payload. */
const subscriptionBody = () => ({
enabled: true,
name: 'STUN port changed',
remark: 'local test',
sourceConfig: {
ddnsRecordId: STRING_ID,
portForwardId: '1234567890123456789',
},
sourceKey: 'network.stun.mapping-port-changed',
});
/** Builds one valid strict template payload. */
const templateBody = () => ({
content: 'Endpoint: ${{endpoint}}',
enabled: true,
name: 'STUN template',
remark: 'local test',
sourceKey: 'network.stun.mapping-port-changed',
});
/** Builds one valid strict account binding payload with a selectable target count. */
const bindingBody = (targetCount = 1) => ({
enabled: true,
subscriptionId: STRING_ID,
targets: Array.from({ length: targetCount }, (_, index) => ({
targetId: `${10000 + index}`,
targetName: `Target ${index}`,
targetType: index % 2 === 0 ? 'group' : 'private',
})),
templateId: '1234567890123456789',
});
/** Returns a complete public source definition plus an internal field to detect leakage. */
const sourceDefinition = () =>
({
credential: 'must-not-leak',
description: 'STUN endpoint port change',
displayName: 'STUN port changed',
sourceKey: 'network.stun.mapping-port-changed',
subscriptionFields: [
{
credential: 'must-not-leak',
key: 'portForwardId',
label: 'Port forward',
optionCollection: 'portForwards',
required: true,
type: 'select',
},
{
dependsOn: 'portForwardId',
key: 'ddnsRecordId',
label: 'DDNS',
optionCollection: 'ddnsRecords',
required: true,
type: 'select',
},
],
variables: [
{
accessToken: 'must-not-leak',
description: 'Endpoint',
example: 'example.test:10000',
key: 'endpoint',
label: 'Endpoint',
type: 'string',
},
],
version: 1,
}) as never;
/** Returns a detached subscription view plus persistence-only fields to detect leakage. */
const subscriptionView = () =>
({
activeKey: 'must-not-leak',
createTime: '2026-07-24T00:00:00.000Z',
enabled: true,
id: STRING_ID,
invalidReasonCode: null,
isDeleted: false,
name: 'STUN port changed',
remark: null,
sourceConfig: {
credential: 'must-not-leak',
ddnsRecordId: STRING_ID,
portForwardId: '1234567890123456789',
},
sourceConfigDigest: 'must-not-leak',
sourceKey: 'network.stun.mapping-port-changed',
sourceName: 'STUN port changed',
sourceSummary: 'summary',
updateTime: '2026-07-24T00:00:00.000Z',
valid: true,
}) as never;
/** Returns a detached template view plus persistence-only fields to detect leakage. */
const templateView = () =>
({
activeKey: 'must-not-leak',
content: 'Endpoint: ${{endpoint}}',
createTime: '2026-07-24T00:00:00.000Z',
enabled: true,
id: STRING_ID,
isDeleted: false,
name: 'STUN template',
referenceCount: 1,
remark: null,
sourceKey: 'network.stun.mapping-port-changed',
sourceName: 'STUN port changed',
updateTime: '2026-07-24T00:00:00.000Z',
}) as never;
/** Returns a detached binding view plus account and active-key fields to detect leakage. */
const bindingView = () =>
({
accountId: 'must-not-leak',
activeKey: 'must-not-leak',
available: true,
createTime: '2026-07-24T00:00:00.000Z',
enabled: true,
id: STRING_ID,
invalidReasonCode: null,
selfId: SELF_ID,
sourceKey: 'network.stun.mapping-port-changed',
sourceName: 'STUN port changed',
subscriptionId: STRING_ID,
subscriptionName: 'STUN port changed',
targets: [
{
activeKey: 'must-not-leak',
bindingId: STRING_ID,
enabled: true,
id: '1234567890123456789',
targetId: '10000',
targetName: 'Target',
targetType: 'group',
},
],
templateId: '1234567890123456789',
templateName: 'STUN template',
updateTime: '2026-07-24T00:00:00.000Z',
}) as never;
/** Recursively collects response object keys for sensitive-field assertions. */
const collectKeys = (value: unknown): string[] => {
if (Array.isArray(value)) return value.flatMap(collectKeys);
if (!value || typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, nested]) => [
key,
...collectKeys(nested),
]);
};
describe('QQBot message-push management controllers', () => {
let app: INestApplication;
let apiUrl: string;
const registry = {
get: jest.fn(),
list: jest.fn(),
};
const subscriptions = {
create: jest.fn(),
page: jest.fn(),
remove: jest.fn(),
setEnabled: jest.fn(),
update: jest.fn(),
};
const templates = {
create: jest.fn(),
page: jest.fn(),
preview: jest.fn(),
remove: jest.fn(),
setEnabled: jest.fn(),
update: jest.fn(),
};
const bindings = {
createBinding: jest.fn(),
listBindings: jest.fn(),
removeBinding: jest.fn(),
setBindingEnabled: jest.fn(),
updateBinding: jest.fn(),
};
const targets = {
listTargetOptions: jest.fn(),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [
QqbotMessagePushController,
QqbotAccountMessagePushController,
],
providers: [
QqbotMessagePushPermissionGuard,
{ provide: SystemMessageSourceRegistry, useValue: registry },
{ provide: QqbotMessageSubscriptionService, useValue: subscriptions },
{ provide: QqbotMessageTemplateService, useValue: templates },
{ provide: QqbotAccountMessagePushService, useValue: bindings },
{ provide: QqbotMessageTargetOptionsService, useValue: targets },
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(QqbotMessagePushPermissionGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication();
app.useGlobalFilters(
new ApiExceptionFilter(pinoLogger as unknown as PinoLogger),
);
await app.listen(0, '127.0.0.1');
apiUrl = await app.getUrl();
});
beforeEach(() => {
jest.clearAllMocks();
const definition = sourceDefinition();
registry.list.mockReturnValue([definition]);
registry.get.mockReturnValue({
credential: 'must-not-leak',
definition,
listSubscriptionOptions: jest.fn().mockResolvedValue({
credential: 'must-not-leak',
ddnsRecords: [
{
credential: 'must-not-leak',
disabledReasonCode: null,
eligible: true,
fqdn: 'example.test',
id: STRING_ID,
name: 'DDNS',
portForwardId: '1234567890123456789',
},
],
portForwards: [
{
accessToken: 'must-not-leak',
disabledReasonCode: null,
eligible: true,
externalPort: 10000,
id: '1234567890123456789',
internalPort: 10000,
name: 'STUN',
protocol: 'udp',
},
],
}),
});
subscriptions.page.mockResolvedValue({
items: [subscriptionView()],
total: 1,
});
subscriptions.create.mockResolvedValue(subscriptionView());
subscriptions.update.mockResolvedValue(subscriptionView());
subscriptions.setEnabled.mockResolvedValue(subscriptionView());
subscriptions.remove.mockResolvedValue(true);
templates.page.mockResolvedValue({ items: [templateView()], total: 1 });
templates.create.mockResolvedValue(templateView());
templates.update.mockResolvedValue(templateView());
templates.setEnabled.mockResolvedValue(templateView());
templates.remove.mockResolvedValue(true);
templates.preview.mockReturnValue({
payload: 'must-not-leak',
renderedMessage: 'Endpoint: example.test:10000',
variables: { endpoint: 'example.test:10000' },
});
bindings.listBindings.mockResolvedValue([bindingView()]);
bindings.createBinding.mockResolvedValue(bindingView());
bindings.updateBinding.mockResolvedValue(bindingView());
bindings.setBindingEnabled.mockResolvedValue(bindingView());
bindings.removeBinding.mockResolvedValue(true);
targets.listTargetOptions.mockResolvedValue({
available: false,
options: [],
reasonCode: 'onebot_unavailable',
});
});
afterAll(async () => {
await app?.close();
});
it('exposes exactly the approved 20 routes with exact permission metadata', () => {
const routes = collectControllerRoutes([
QqbotMessagePushController,
QqbotAccountMessagePushController,
]);
const routeKeys = routes.map(routeKey);
expect(routeKeys).toEqual(Object.keys(EXPECTED_ROUTE_PERMISSIONS).sort());
expect(
routeKeys.some((route) =>
/\/(?:publish|events|deliveries|fanout|retry)(?:\/|$)/.test(route),
),
).toBe(false);
routes.forEach((route) => {
const ControllerClass =
route.controllerName === QqbotMessagePushController.name
? QqbotMessagePushController
: QqbotAccountMessagePushController;
const handler = ControllerClass.prototype[route.handlerName];
expect(
Reflect.getMetadata(QQBOT_MESSAGE_PUSH_PERMISSION, handler),
).toEqual(EXPECTED_ROUTE_PERMISSIONS[routeKey(route)]);
});
});
it('uses page wrappers only for subscription and template pages', async () => {
const sourceResponse = await request(apiUrl)
.get('/qqbot/message-push/sources')
.expect(200);
const subscriptionResponse = await request(apiUrl)
.get('/qqbot/message-push/subscriptions')
.expect(200);
const templateResponse = await request(apiUrl)
.get('/qqbot/message-push/templates')
.expect(200);
const bindingResponse = await request(apiUrl)
.get('/qqbot/accounts/12345/message-push/bindings')
.expect(200);
expect(Array.isArray(sourceResponse.body.data)).toBe(true);
expect(subscriptionResponse.body.data).toMatchObject({ total: 1 });
expect(Array.isArray(subscriptionResponse.body.data.items)).toBe(true);
expect(templateResponse.body.data).toMatchObject({ total: 1 });
expect(Array.isArray(templateResponse.body.data.items)).toBe(true);
expect(Array.isArray(bindingResponse.body.data)).toBe(true);
});
it('keeps unavailable target lookup at HTTP 200 with an exact safe shape', async () => {
const response = await request(apiUrl)
.get('/qqbot/accounts/12345/message-push/targets')
.expect(200);
expect(response.body.data).toEqual({
available: false,
options: [],
reasonCode: 'onebot_unavailable',
});
});
it('attaches both guards and a strict controller-local ValidationPipe', () => {
for (const ControllerClass of [
QqbotMessagePushController,
QqbotAccountMessagePushController,
]) {
expect(Reflect.getMetadata(GUARDS_METADATA, ControllerClass)).toEqual([
JwtAuthGuard,
QqbotMessagePushPermissionGuard,
]);
const pipes = Reflect.getMetadata(PIPES_METADATA, ControllerClass);
expect(pipes).toHaveLength(1);
expect(pipes[0]).toBeInstanceOf(ValidationPipe);
expect(pipes[0].validatorOptions).toMatchObject({
forbidNonWhitelisted: true,
whitelist: true,
});
expect(pipes[0].isTransformEnabled).toBe(true);
}
});
it('shares one contract-error boundary across both message-push controllers', () => {
for (const ControllerClass of [
QqbotMessagePushController,
QqbotAccountMessagePushController,
]) {
expect(
Reflect.getMetadata(INTERCEPTORS_METADATA, ControllerClass),
).toEqual([QqbotMessagePushContractErrorInterceptor]);
}
});
it('maps a synchronous unknown source registry error to a safe HTTP 404', async () => {
registry.get.mockImplementationOnce(() => {
throw new SystemMessageContractError('unknown_message_source');
});
const response = await request(apiUrl)
.get('/qqbot/message-push/sources/missing-source')
.expect(404);
expect(response.body).toEqual({
code: 404,
err: 'unknown_message_source',
msg: 'unknown_message_source',
});
});
it('maps template contract errors to a safe HTTP 400', async () => {
templates.preview.mockImplementationOnce(() => {
throw new SystemMessageContractError('template_invalid');
});
const response = await request(apiUrl)
.post('/qqbot/message-push/templates/preview')
.send({
content: 'Endpoint: ${{endpoint}}',
sourceKey: 'network.stun.mapping-port-changed',
})
.expect(400);
expect(response.body).toEqual({
code: 400,
err: 'template_invalid',
msg: 'template_invalid',
});
});
it.each(['account_unavailable', 'ddns_not_synced'])(
'maps async account binding contract error %s to a safe HTTP 409',
async (code) => {
bindings.createBinding.mockRejectedValueOnce(
new SystemMessageContractError(code),
);
const response = await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody())
.expect(409);
expect(response.body).toEqual({
code: 409,
err: code,
msg: code,
});
},
);
it('leaves ordinary failures at HTTP 500 without leaking their detail', async () => {
templates.preview.mockImplementationOnce(() => {
throw new Error('database password must-not-leak');
});
const response = await request(apiUrl)
.post('/qqbot/message-push/templates/preview')
.send({
content: 'Endpoint: ${{endpoint}}',
sourceKey: 'network.stun.mapping-port-changed',
})
.expect(500);
expect(response.body).toEqual({
code: 500,
err: 'Internal server error',
msg: 'Internal server error',
});
expect(JSON.stringify(response.body)).not.toContain('must-not-leak');
});
it('returns HTTP 200 and a Vben wrapper for every POST route', async () => {
const responses = await Promise.all([
request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send(subscriptionBody())
.expect(200),
request(apiUrl)
.post('/qqbot/message-push/templates')
.send(templateBody())
.expect(200),
request(apiUrl)
.post('/qqbot/message-push/templates/preview')
.send({
content: 'Endpoint: ${{endpoint}}',
sourceKey: 'network.stun.mapping-port-changed',
})
.expect(200),
request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody())
.expect(200),
]);
responses.forEach((response) => {
expect(response.body).toEqual(
expect.objectContaining({ code: 200, msg: expect.any(String) }),
);
});
});
it.each([
['/qqbot/message-push/subscriptions', subscriptionBody()],
['/qqbot/message-push/templates', templateBody()],
[
'/qqbot/message-push/templates/preview',
{
content: 'Endpoint: ${{endpoint}}',
sourceKey: 'network.stun.mapping-port-changed',
},
],
[`/qqbot/accounts/${SELF_ID}/message-push/bindings`, bindingBody()],
])('rejects unknown root request keys on POST %s', async (path, body) => {
await request(apiUrl)
.post(path)
.send({ ...body, credential: 'must-reject' })
.expect(400);
});
it('rejects unknown enabled, sourceConfig, and target fields', async () => {
await request(apiUrl)
.put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`)
.send({ enabled: false, secret: 'must-reject' })
.expect(400);
await request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send({
...subscriptionBody(),
sourceConfig: {
...subscriptionBody().sourceConfig,
credential: 'must-reject',
},
})
.expect(400);
const binding = bindingBody();
binding.targets[0] = {
...binding.targets[0],
accessToken: 'must-reject',
} as never;
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(binding)
.expect(400);
});
it.each([
['missing', undefined],
['null', null],
['array', []],
])(
'rejects %s sourceConfig instead of skipping nested validation',
async (_label, sourceConfig) => {
const body = subscriptionBody() as Record<string, unknown>;
if (sourceConfig === undefined) {
delete body.sourceConfig;
} else {
body.sourceConfig = sourceConfig;
}
await request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send(body)
.expect(400);
},
);
it('rejects numeric foreign IDs and preserves long decimal strings', async () => {
await request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send({
...subscriptionBody(),
sourceConfig: {
ddnsRecordId: 123,
portForwardId: '1234567890123456789',
},
})
.expect(400);
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send({ ...bindingBody(), subscriptionId: 123 })
.expect(400);
await request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send(subscriptionBody())
.expect(200);
expect(subscriptions.create).toHaveBeenCalledWith(
expect.objectContaining({
sourceConfig: expect.objectContaining({ ddnsRecordId: STRING_ID }),
}),
);
expect(
typeof subscriptions.create.mock.calls[0][0].sourceConfig.ddnsRecordId,
).toBe('string');
});
it.each(['0', '-1', '1.5', '1e5', '%20123', '1234567890123456789012345'])(
'rejects invalid Snowflake path id %s',
async (id) => {
await request(apiUrl)
.delete(`/qqbot/message-push/subscriptions/${id}`)
.expect(400);
},
);
it.each(['0', '1234', '-12345', '1.2345', '1e9999', '123456789012345678901'])(
'rejects invalid selfId %s',
async (selfId) => {
await request(apiUrl)
.get(`/qqbot/accounts/${selfId}/message-push/bindings`)
.expect(400);
},
);
it('transforms only literal query booleans', async () => {
await request(apiUrl)
.get('/qqbot/message-push/subscriptions?enabled=false')
.expect(200);
expect(subscriptions.page).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
for (const value of ['0', 'yes', '']) {
await request(apiUrl)
.get(`/qqbot/message-push/subscriptions?enabled=${value}`)
.expect(400);
}
});
it.each([
['target type', { targetType: 'channel' }],
['short target id', { targetId: '1234' }],
['zero target id', { targetId: '01234' }],
['numeric target id', { targetId: 12345 }],
])('rejects invalid %s', async (_label, override) => {
const body = bindingBody();
body.targets[0] = { ...body.targets[0], ...override } as never;
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(body)
.expect(400);
});
it('enforces target count 1..100 with nested transformation', async () => {
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody(0))
.expect(400);
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody(101))
.expect(400);
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody(1))
.expect(200);
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(bindingBody(100))
.expect(200);
expect(bindings.createBinding.mock.calls.at(-1)?.[1].targets).toHaveLength(
100,
);
});
it('enforces name, remark, target name, and content boundaries', async () => {
for (const body of [
{ ...subscriptionBody(), name: ' '.repeat(2) },
{ ...subscriptionBody(), name: 'x'.repeat(101) },
{ ...subscriptionBody(), remark: 'x'.repeat(501) },
]) {
await request(apiUrl)
.post('/qqbot/message-push/subscriptions')
.send(body)
.expect(400);
}
await request(apiUrl)
.post('/qqbot/message-push/templates')
.send({ ...templateBody(), content: '😀'.repeat(2001) })
.expect(400);
const binding = bindingBody();
binding.targets[0].targetName = 'x'.repeat(121);
await request(apiUrl)
.post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.send(binding)
.expect(400);
});
it('returns only exact public response fields and recursively excludes secrets', async () => {
targets.listTargetOptions.mockResolvedValueOnce({
accessToken: 'must-not-leak',
available: true,
options: [
{
credential: 'must-not-leak',
label: 'Target (10000)',
targetId: '10000',
targetType: 'group',
},
],
reasonCode: null,
});
const responses = await Promise.all([
request(apiUrl).get('/qqbot/message-push/sources').expect(200),
request(apiUrl)
.get('/qqbot/message-push/sources/network.stun.mapping-port-changed')
.expect(200),
request(apiUrl)
.get(
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
)
.expect(200),
request(apiUrl).get('/qqbot/message-push/subscriptions').expect(200),
request(apiUrl).get('/qqbot/message-push/templates').expect(200),
request(apiUrl)
.post('/qqbot/message-push/templates/preview')
.send({
content: 'Endpoint: ${{endpoint}}',
sourceKey: 'network.stun.mapping-port-changed',
})
.expect(200),
request(apiUrl)
.get(`/qqbot/accounts/${SELF_ID}/message-push/bindings`)
.expect(200),
request(apiUrl)
.get(`/qqbot/accounts/${SELF_ID}/message-push/targets`)
.expect(200),
]);
const [
sourceList,
sourceDetail,
options,
subscriptionPage,
templatePage,
preview,
bindingList,
targetOptions,
] = responses.map((response) => response.body.data);
expect(Object.keys(sourceList[0]).sort()).toEqual(
[
'description',
'displayName',
'sourceKey',
'subscriptionFields',
'variables',
'version',
].sort(),
);
expect(Object.keys(sourceDetail).sort()).toEqual(
Object.keys(sourceList[0]).sort(),
);
expect(Object.keys(sourceList[0].subscriptionFields[0]).sort()).toEqual(
['key', 'label', 'optionCollection', 'required', 'type'].sort(),
);
expect(Object.keys(sourceList[0].subscriptionFields[1]).sort()).toEqual(
[
'dependsOn',
'key',
'label',
'optionCollection',
'required',
'type',
].sort(),
);
expect(Object.keys(sourceList[0].variables[0]).sort()).toEqual(
['description', 'example', 'key', 'label', 'type'].sort(),
);
expect(Object.keys(options).sort()).toEqual([
'ddnsRecords',
'portForwards',
]);
expect(Object.keys(options.ddnsRecords[0]).sort()).toEqual(
[
'disabledReasonCode',
'eligible',
'fqdn',
'id',
'name',
'portForwardId',
].sort(),
);
expect(Object.keys(options.portForwards[0]).sort()).toEqual(
[
'disabledReasonCode',
'eligible',
'externalPort',
'id',
'internalPort',
'name',
'protocol',
].sort(),
);
expect(Object.keys(subscriptionPage.items[0]).sort()).toEqual(
[
'createTime',
'enabled',
'id',
'invalidReasonCode',
'name',
'remark',
'sourceConfig',
'sourceKey',
'sourceName',
'sourceSummary',
'updateTime',
'valid',
].sort(),
);
expect(Object.keys(subscriptionPage.items[0].sourceConfig).sort()).toEqual([
'ddnsRecordId',
'portForwardId',
]);
expect(Object.keys(templatePage.items[0]).sort()).toEqual(
[
'content',
'createTime',
'enabled',
'id',
'name',
'referenceCount',
'remark',
'sourceKey',
'sourceName',
'updateTime',
].sort(),
);
expect(Object.keys(preview).sort()).toEqual([
'renderedMessage',
'variables',
]);
expect(Object.keys(bindingList[0]).sort()).toEqual(
[
'available',
'createTime',
'enabled',
'id',
'invalidReasonCode',
'sourceKey',
'sourceName',
'subscriptionId',
'subscriptionName',
'targets',
'templateId',
'templateName',
'updateTime',
].sort(),
);
expect(Object.keys(bindingList[0].targets[0]).sort()).toEqual(
['enabled', 'id', 'targetId', 'targetName', 'targetType'].sort(),
);
expect(Object.keys(targetOptions).sort()).toEqual(
['available', 'options', 'reasonCode'].sort(),
);
expect(Object.keys(targetOptions.options[0]).sort()).toEqual(
['label', 'targetId', 'targetType'].sort(),
);
const forbiddenKeys =
/accessToken|credential|secret|password|activeKey|sourceConfigDigest|payload|accountId|selfId|bindingId|isDeleted/i;
responses.forEach((response) => {
expect(collectKeys(response.body).join('\n')).not.toMatch(forbiddenKeys);
});
});
});