feat: 实现安全消息模板

This commit is contained in:
sunlei 2026-07-24 05:44:48 +08:00
parent ce60b45575
commit 3686e82604
5 changed files with 843 additions and 0 deletions

View File

@ -0,0 +1,325 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Like, Repository, type FindOptionsWhere } from 'typeorm';
import type {
MessageTemplateInput,
MessageTemplateListQuery,
MessageTemplatePreview,
MessageTemplateView,
SystemMessageSourceDefinition,
} from '../../contract/message-push/qqbot-message-push.types';
import { SystemMessageContractError } from '../../contract/message-push/qqbot-message-push.types';
import { QqbotMessagePublishBinding } from '../../infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
import { QqbotMessageTemplate } from '../../infrastructure/persistence/message-push/qqbot-message-template.entity';
import { SystemMessageSourceRegistry } from './system-message-source.registry';
import { SystemMessageTemplateRendererService } from './system-message-template-renderer.service';
const DEFAULT_PAGE_NO = 1;
const DEFAULT_PAGE_SIZE = 10;
/**
* Manages reusable, source-scoped safe templates and the binding availability gate.
*
* Repository exceptions intentionally propagate so storage failures cannot be treated
* as valid domain state.
*/
@Injectable()
export class QqbotMessageTemplateService {
/**
* Initializes template lifecycle dependencies.
* @param templateRepository - Persistence for global message templates.
* @param bindingRepository - Persistence used solely to count live template references.
* @param sourceRegistry - Current process-local source definitions and allowed variables.
* @param renderer - Shared strict parser and pure scalar renderer.
*/
constructor(
@InjectRepository(QqbotMessageTemplate)
private readonly templateRepository: Repository<QqbotMessageTemplate>,
@InjectRepository(QqbotMessagePublishBinding)
private readonly bindingRepository: Repository<QqbotMessagePublishBinding>,
private readonly sourceRegistry: SystemMessageSourceRegistry,
private readonly renderer: SystemMessageTemplateRendererService,
) {}
/**
* Pages non-deleted templates with source display names and live binding counts.
* @param query - Optional name, source, enabled, and page filters.
* @returns Visible template views and their total count; deleted templates never appear.
*/
async page(query: MessageTemplateListQuery): Promise<{
items: MessageTemplateView[];
total: number;
}> {
const pageNo = Math.max(
DEFAULT_PAGE_NO,
Math.floor(query.pageNo ?? DEFAULT_PAGE_NO),
);
const pageSize = Math.max(
1,
Math.floor(query.pageSize ?? DEFAULT_PAGE_SIZE),
);
const where: FindOptionsWhere<QqbotMessageTemplate> = { isDeleted: false };
if (query.name) where.name = Like(`%${query.name}%`);
if (query.sourceKey) where.sourceKey = query.sourceKey;
if (query.enabled !== undefined) where.enabled = query.enabled;
const [templates, total] = await this.templateRepository.findAndCount({
order: { createTime: 'DESC' },
skip: (pageNo - 1) * pageSize,
take: pageSize,
where,
});
return {
items: await Promise.all(templates.map((item) => this.toView(item))),
total,
};
}
/**
* Creates one source-valid global template.
* @param input - Template metadata, source identity, content, and initial enabled state.
* @returns The persisted template view with a zero or current reference count.
*/
async create(input: MessageTemplateInput): Promise<MessageTemplateView> {
this.validateInput(input);
const saved = await this.templateRepository.save(
this.templateRepository.create(this.toPersistenceInput(input)),
);
return this.toView(saved);
}
/**
* Updates one non-deleted template after revalidating it against the current source.
* @param id - String Snowflake identity of the template.
* @param input - Complete replacement metadata and content.
* @returns The updated template view.
*/
async update(
id: string,
input: MessageTemplateInput,
): Promise<MessageTemplateView> {
const current = await this.findActive(id);
this.validateInput(input);
Object.assign(current, this.toPersistenceInput(input));
return this.toView(await this.templateRepository.save(current));
}
/**
* Toggles one template, revalidating historical content before it can be enabled.
* @param id - String Snowflake identity of the template.
* @param enabled - Requested future enabled state.
* @returns The persisted template view; disabling leaves existing bindings untouched.
*/
async setEnabled(id: string, enabled: boolean): Promise<MessageTemplateView> {
const current = await this.findActive(id);
if (enabled) this.validateContent(current.content, current.sourceKey);
current.enabled = enabled;
return this.toView(await this.templateRepository.save(current));
}
/**
* Soft-deletes a template that has no live publish binding references.
* @param id - String Snowflake identity of the template.
* @returns `true` after setting both `isDeleted` and `enabled` to false-safe values.
* @throws {SystemMessageContractError} When any non-deleted binding still references it.
*/
async remove(id: string): Promise<boolean> {
const current = await this.findActive(id);
const referenceCount = await this.bindingRepository.count({
where: { isDeleted: false, templateId: id },
});
if (referenceCount > 0)
throw new SystemMessageContractError('template_invalid');
current.enabled = false;
current.isDeleted = true;
await this.templateRepository.save(current);
return true;
}
/**
* Renders source examples through the production renderer without a parallel preview path.
* @param input - Candidate content and its exact source identity.
* @returns Rendered plain text and typed source example variables.
*/
preview(input: {
content: string;
sourceKey: string;
}): MessageTemplatePreview {
const definition = this.sourceRegistry.get(input.sourceKey).definition;
const variables = this.exampleVariables(definition);
return {
renderedMessage: this.renderer.render(input.content, variables),
variables,
};
}
/**
* Enforces the reusable template gate before a publish binding is created or enabled.
* @param templateId - Candidate template's string Snowflake identity.
* @param sourceKey - Source subscribed by the binding.
* @param bindingEnabled - Whether the binding is being created or enabled for delivery.
* @returns The existing non-deleted template when it is valid for this binding state.
* @throws {SystemMessageContractError} With `template_invalid` for every unavailable binding choice.
*/
async requireAvailableForBinding(
templateId: string,
sourceKey: string,
bindingEnabled: boolean,
): Promise<QqbotMessageTemplate> {
const template = await this.templateRepository.findOne({
where: { id: templateId, isDeleted: false },
});
if (!template || template.sourceKey !== sourceKey) {
throw new SystemMessageContractError('template_invalid');
}
if (bindingEnabled) {
if (!template.enabled)
throw new SystemMessageContractError('template_invalid');
this.validateContentForBinding(template.content, template.sourceKey);
}
return template;
}
/**
* Validates a create or update payload using its currently registered source contract.
* @param input - Candidate source key and template content.
* @returns Nothing after successful strict rendering-protocol validation.
*/
private validateInput(input: MessageTemplateInput): void {
const definition = this.sourceRegistry.get(input.sourceKey).definition;
this.renderer.validate(
input.content,
definition.variables.map((variable) => variable.key),
);
}
/**
* Revalidates stored content against the source definition currently in the registry.
* @param content - Persisted or candidate template text.
* @param sourceKey - Source whose exact current variables are authoritative.
* @returns Nothing when content remains valid; parser errors retain their stable code.
*/
private validateContent(content: string, sourceKey: string): void {
const definition = this.sourceRegistry.get(sourceKey).definition;
this.renderer.validate(
content,
definition.variables.map((variable) => variable.key),
);
}
/**
* Validates content for a newly enabled binding and maps a missing current source to the gate error.
* @param content - Stored template text selected by the binding.
* @param sourceKey - Source identity already matched to the subscription.
* @returns Nothing when the current source still accepts this template.
* @throws {SystemMessageContractError} With `template_invalid` when its source is unavailable or content is invalid.
*/
private validateContentForBinding(content: string, sourceKey: string): void {
try {
this.validateContent(content, sourceKey);
} catch (error) {
if (
error instanceof SystemMessageContractError &&
error.code === 'unknown_message_source'
) {
throw new SystemMessageContractError('template_invalid');
}
throw error;
}
}
/**
* Normalizes user-facing metadata into the entity fields shared by create and update.
* @param input - Validated complete template input.
* @returns Persistence fields with blank remarks represented as `null`.
*/
private toPersistenceInput(
input: MessageTemplateInput,
): Pick<
QqbotMessageTemplate,
'content' | 'enabled' | 'name' | 'remark' | 'sourceKey'
> {
return {
content: input.content,
enabled: input.enabled,
name: input.name.trim(),
remark: input.remark?.trim() || null,
sourceKey: input.sourceKey,
};
}
/**
* Loads an existing non-deleted template for a direct lifecycle operation.
* @param id - String Snowflake identity to look up.
* @returns The active entity or throws the stable template availability error.
*/
private async findActive(id: string): Promise<QqbotMessageTemplate> {
const template = await this.templateRepository.findOne({
where: { id, isDeleted: false },
});
if (!template) throw new SystemMessageContractError('template_invalid');
return template;
}
/**
* Converts source definition examples to the scalar types used by actual rendering.
* @param definition - Current source schema that owns variable examples and types.
* @returns A source-keyed scalar object; invalid examples throw `template_invalid`.
*/
private exampleVariables(
definition: SystemMessageSourceDefinition,
): Record<string, boolean | number | string> {
return Object.fromEntries(
definition.variables.map((variable) => {
if (variable.type === 'string') return [variable.key, variable.example];
if (variable.type === 'number') {
const value = Number(variable.example);
if (!Number.isFinite(value)) {
throw new SystemMessageContractError('template_invalid');
}
return [variable.key, value];
}
if (variable.example === 'true') return [variable.key, true];
if (variable.example === 'false') return [variable.key, false];
throw new SystemMessageContractError('template_invalid');
}),
);
}
/**
* Maps an entity to the external template contract without exposing datetime objects.
* @param template - Persisted template entity.
* @returns Serializable view including source display name and all live binding references.
*/
private async toView(
template: QqbotMessageTemplate,
): Promise<MessageTemplateView> {
const [referenceCount, source] = await Promise.all([
this.bindingRepository.count({
where: { isDeleted: false, templateId: template.id },
}),
Promise.resolve(this.sourceRegistry.get(template.sourceKey).definition),
]);
return {
content: template.content,
createTime: this.serializeTime(template.createTime),
enabled: template.enabled,
id: String(template.id),
name: template.name,
referenceCount,
remark: template.remark?.trim() || null,
sourceKey: template.sourceKey,
sourceName: source.displayName,
updateTime: this.serializeTime(template.updateTime),
};
}
/**
* Serializes project datetime values at the message-push JSON boundary.
* @param value - Entity datetime value supplied by the KtDateTime TypeORM transformer.
* @returns The project-configured KtDateTime string, without converting it to UTC ISO text.
*/
private serializeTime(value: QqbotMessageTemplate['createTime']): string {
return String(value);
}
}

View File

@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import {
SystemMessageContractError,
type SystemMessageTemplateToken,
} from '../../contract/message-push/qqbot-message-push.types';
const DANGEROUS_VARIABLE_KEYS = new Set([
'__proto__',
'constructor',
'prototype',
]);
const VARIABLE_IDENTIFIER = /^[A-Za-z][A-Za-z0-9]*$/;
const MAX_TEMPLATE_CONTENT_CODE_POINTS = 2_000;
const MAX_VARIABLE_STRING_CODE_POINTS = 500;
const MAX_RENDERED_MESSAGE_CODE_POINTS = 4_000;
/** Counts Unicode code points instead of UTF-16 code units. */
function codePointLength(value: string): number {
return Array.from(value).length;
}
/** Throws the stable error used for every malformed template protocol input. */
function throwTemplateInvalid(): never {
throw new SystemMessageContractError('template_invalid');
}
/**
* Parses and renders the deliberately small system-message template protocol.
*
* It recognizes only complete `${{identifier}}` tokens and never evaluates an
* expression, traverses object properties, or interprets CQ-looking text.
*/
@Injectable()
export class SystemMessageTemplateRendererService {
/**
* Parses one template into ordered text and allowlisted variable tokens.
* @param content - Untrusted template text, limited to 2,000 Unicode code points.
* @param allowedVariables - Exact variable names provided by the source definition.
* @returns Consecutive ordered text and variable tokens; malformed syntax throws `template_invalid`.
*/
parse(
content: string,
allowedVariables: readonly string[],
): SystemMessageTemplateToken[] {
if (typeof content !== 'string') throwTemplateInvalid();
if (codePointLength(content) > MAX_TEMPLATE_CONTENT_CODE_POINTS) {
throwTemplateInvalid();
}
const allowed = new Set(allowedVariables);
const tokens: SystemMessageTemplateToken[] = [];
let cursor = 0;
while (cursor < content.length) {
const openingIndex = content.indexOf('${{', cursor);
const closingIndex = content.indexOf('}}', cursor);
if (openingIndex === -1) {
if (closingIndex !== -1) throwTemplateInvalid();
this.appendText(tokens, content.slice(cursor));
break;
}
if (closingIndex !== -1 && closingIndex < openingIndex) {
throwTemplateInvalid();
}
this.appendText(tokens, content.slice(cursor, openingIndex));
const tokenEnd = content.indexOf('}}', openingIndex + 3);
const nestedOpeningIndex = content.indexOf('${{', openingIndex + 3);
if (
tokenEnd === -1 ||
(nestedOpeningIndex !== -1 && nestedOpeningIndex < tokenEnd)
) {
throwTemplateInvalid();
}
const key = content.slice(openingIndex + 3, tokenEnd);
if (
!VARIABLE_IDENTIFIER.test(key) ||
DANGEROUS_VARIABLE_KEYS.has(key) ||
!allowed.has(key)
) {
throwTemplateInvalid();
}
tokens.push({ key, kind: 'variable' });
cursor = tokenEnd + 2;
// A third closing brace overlaps the token's closing pair and is still an extra `}}`.
if (content[cursor] === '}') throwTemplateInvalid();
}
return tokens;
}
/**
* Validates the same token stream used by rendering without producing output.
* @param content - Untrusted template text.
* @param allowedVariables - Exact source-defined variable names.
* @returns Nothing when the template protocol is safe; otherwise throws `template_invalid`.
*/
validate(content: string, allowedVariables: readonly string[]): void {
this.parse(content, allowedVariables);
}
/**
* Replaces validated variable tokens with scalar strings and leaves all other text literal.
* @param content - Template content following the strict `${{identifier}}` protocol.
* @param variables - Own scalar variables available to this render operation.
* @returns Plain rendered message text, capped at 4,000 Unicode code points.
* @throws {SystemMessageContractError} For invalid templates, invalid values, or size limits.
*/
render(
content: string,
variables: Record<string, boolean | number | string>,
): string {
if (
!variables ||
typeof variables !== 'object' ||
Array.isArray(variables)
) {
throwTemplateInvalid();
}
const tokens = this.parse(content, Object.keys(variables));
for (const value of Object.values(variables)) {
if (typeof value === 'string') {
if (codePointLength(value) > MAX_VARIABLE_STRING_CODE_POINTS) {
throw new SystemMessageContractError('template_variable_too_long');
}
continue;
}
if (
typeof value !== 'boolean' &&
(typeof value !== 'number' || !Number.isFinite(value))
) {
throwTemplateInvalid();
}
}
const rendered = tokens
.map((token) => {
if (token.kind === 'text') return token.value;
const value = variables[token.key];
if (!Object.prototype.hasOwnProperty.call(variables, token.key)) {
throwTemplateInvalid();
}
return String(value);
})
.join('');
if (codePointLength(rendered) > MAX_RENDERED_MESSAGE_CODE_POINTS) {
throw new SystemMessageContractError('rendered_message_too_long');
}
return rendered;
}
/**
* Adds literal text while preserving the compact token representation.
* @param tokens - Mutable parser result in source order.
* @param value - Literal source slice to append.
* @returns Nothing; adjacent literal slices are merged in place.
*/
private appendText(
tokens: SystemMessageTemplateToken[],
value: string,
): void {
if (!value) return;
const previous = tokens.at(-1);
if (previous?.kind === 'text') {
previous.value += value;
return;
}
tokens.push({ kind: 'text', value });
}
}

View File

@ -48,6 +48,8 @@ import { QqbotMessagePublishTarget } from '@/modules/qqbot/core/infrastructure/p
import { QqbotMessageSubscription } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
import { QqbotMessageTemplate } from '@/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
import { SystemMessageSourceRegistry } from './application/message-push/system-message-source.registry';
import { SystemMessageTemplateRendererService } from './application/message-push/system-message-template-renderer.service';
import { QqbotMessageTemplateService } from './application/message-push/qqbot-message-template.service';
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
@ -84,6 +86,8 @@ export const QQBOT_CORE_CONTROLLERS = [
export const QQBOT_CORE_PROVIDERS = [
SystemMessageSourceRegistry,
SystemMessageTemplateRendererService,
QqbotMessageTemplateService,
QqbotAccountService,
QqbotBusService,
QqbotCommandEngineService,
@ -105,6 +109,8 @@ export const QQBOT_CORE_PROVIDERS = [
export const QQBOT_CORE_EXPORTS = [
SystemMessageSourceRegistry,
SystemMessageTemplateRendererService,
QqbotMessageTemplateService,
QqbotAccountService,
QqbotConfigService,
QqbotDashboardService,

View File

@ -0,0 +1,242 @@
import type { Repository } from 'typeorm';
import { KtDateTime } from '../../../../src/common';
import { SystemMessageTemplateRendererService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-template-renderer.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 { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
import { QqbotMessageTemplate } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
const SOURCE_KEY = 'network.stun.mapping-port-changed';
const NOW = new Date('2026-07-24T00:00:00.000Z');
/** Creates an in-memory template fixture with explicit serializable timestamps. */
function template(
overrides: Partial<QqbotMessageTemplate> = {},
): QqbotMessageTemplate {
return {
content: '端口 ${{port}},就绪:${{ready}}',
createId: jest.fn(),
createTime: NOW as never,
enabled: true,
id: '100',
isDeleted: false,
name: '端口提醒',
remark: null,
sourceKey: SOURCE_KEY,
updateTime: NOW as never,
...overrides,
};
}
/** Registers the source definition used by template lifecycle tests. */
function registry(): SystemMessageSourceRegistry {
const value = new SystemMessageSourceRegistry();
value.register({
definition: {
description: 'STUN 映射端口变化',
displayName: 'STUN 端口变化',
sourceKey: SOURCE_KEY,
subscriptionFields: [],
variables: [
{
description: '端口',
example: '38213',
key: 'port',
label: '端口',
type: 'number',
},
{
description: '就绪',
example: 'true',
key: 'ready',
label: '就绪',
type: 'boolean',
},
{
description: '地址',
example: 'pal.example.com',
key: 'endpoint',
label: '地址',
type: 'string',
},
],
version: 1,
},
inspectSubscription: jest.fn(),
listSubscriptionOptions: jest.fn(),
normalizeSubscriptionConfig: jest.fn(),
resolveDelivery: jest.fn(),
validateEventPayload: jest.fn(),
});
return value;
}
/** Constructs a template service with narrow repository fakes for unit-level lifecycle checks. */
function setup(items: QqbotMessageTemplate[] = [template()]) {
const templateRepository = {
create: jest.fn((input) => template(input)),
findAndCount: jest.fn().mockResolvedValue([items, items.length]),
findOne: jest.fn(
async ({ where: { id } }) =>
items.find((item) => item.id === id && !item.isDeleted) ?? null,
),
save: jest.fn(async (item) => item),
} as unknown as jest.Mocked<Repository<QqbotMessageTemplate>>;
const bindingRepository = {
count: jest.fn().mockResolvedValue(0),
} as unknown as jest.Mocked<Repository<QqbotMessagePublishBinding>>;
const service = new QqbotMessageTemplateService(
templateRepository,
bindingRepository,
registry(),
new SystemMessageTemplateRendererService(),
);
return { bindingRepository, service, templateRepository };
}
describe('QqbotMessageTemplateService', () => {
it('pages undeleted templates with filters, source names, and every live binding reference', async () => {
const { bindingRepository, service, templateRepository } = setup([
template({ id: '100' }),
template({ id: '101', name: '另一模板' }),
]);
bindingRepository.count.mockResolvedValueOnce(2).mockResolvedValueOnce(1);
await expect(
service.page({
enabled: true,
name: '端口',
pageNo: 2,
pageSize: 10,
sourceKey: SOURCE_KEY,
}),
).resolves.toEqual({
items: [
expect.objectContaining({
id: '100',
referenceCount: 2,
sourceName: 'STUN 端口变化',
}),
expect.objectContaining({
id: '101',
referenceCount: 1,
sourceName: 'STUN 端口变化',
}),
],
total: 2,
});
expect(templateRepository.findAndCount).toHaveBeenCalledWith(
expect.objectContaining({ skip: 10, take: 10 }),
);
expect(bindingRepository.count).toHaveBeenNthCalledWith(1, {
where: { isDeleted: false, templateId: '100' },
});
});
it('serializes KtDateTime through the project formatter rather than UTC ISO text', async () => {
const dateTime = new KtDateTime('2026-07-24 08:09:10');
const { service } = setup([
template({ createTime: dateTime, updateTime: dateTime }),
]);
await expect(service.page({})).resolves.toEqual({
items: [
expect.objectContaining({
createTime: String(dateTime),
updateTime: String(dateTime),
}),
],
total: 1,
});
});
it('creates and updates only templates valid for the current source variable definition', async () => {
const { service, templateRepository } = setup();
const input = {
content: '地址 ${{endpoint}}',
enabled: true,
name: '新模板',
remark: ' 说明 ',
sourceKey: SOURCE_KEY,
};
await expect(service.create(input)).resolves.toEqual(
expect.objectContaining({ name: '新模板', remark: '说明' }),
);
await expect(
service.update('100', { ...input, content: '端口 ${{port}}' }),
).resolves.toEqual(expect.objectContaining({ content: '端口 ${{port}}' }));
await expect(
service.create({ ...input, content: '${{unknown}}' }),
).rejects.toMatchObject({ code: 'template_invalid' });
await expect(
service.create({ ...input, sourceKey: 'missing' }),
).rejects.toMatchObject({ code: 'unknown_message_source' });
expect(templateRepository.save).toHaveBeenCalledTimes(2);
});
it('previews source-specific typed examples through the same renderer', () => {
const { service } = setup();
expect(
service.preview({
content: '${{port}}/${{ready}}/${{endpoint}}',
sourceKey: SOURCE_KEY,
}),
).toEqual({
renderedMessage: '38213/true/pal.example.com',
variables: { endpoint: 'pal.example.com', port: 38213, ready: true },
});
});
it('revalidates current source contents before enabling a historical template', async () => {
const historical = template({
content: '${{oldVariable}}',
enabled: false,
});
const { service } = setup([historical]);
await expect(service.setEnabled('100', true)).rejects.toMatchObject({
code: 'template_invalid',
});
await expect(service.setEnabled('100', false)).resolves.toEqual(
expect.objectContaining({ enabled: false }),
);
});
it('blocks soft deletion when any non-deleted binding references the template, including disabled bindings', async () => {
const { bindingRepository, service, templateRepository } = setup();
bindingRepository.count.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
await expect(service.remove('100')).rejects.toMatchObject({
code: 'template_invalid',
});
await expect(service.remove('100')).resolves.toBe(true);
expect(templateRepository.save).toHaveBeenLastCalledWith(
expect.objectContaining({ enabled: false, isDeleted: true }),
);
});
it('gates publish bindings by source, deletion, enabled state, and current template validity', async () => {
const current = template();
const { service } = setup([current]);
await expect(
service.requireAvailableForBinding('100', SOURCE_KEY, true),
).resolves.toBe(current);
current.enabled = false;
await expect(
service.requireAvailableForBinding('100', SOURCE_KEY, true),
).rejects.toMatchObject({ code: 'template_invalid' });
await expect(
service.requireAvailableForBinding('100', SOURCE_KEY, false),
).resolves.toBe(current);
await expect(
service.requireAvailableForBinding('100', 'other.source', false),
).rejects.toMatchObject({ code: 'template_invalid' });
current.isDeleted = true;
await expect(
service.requireAvailableForBinding('100', SOURCE_KEY, false),
).rejects.toMatchObject({ code: 'template_invalid' });
});
});

View File

@ -0,0 +1,98 @@
import { SystemMessageTemplateRendererService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-template-renderer.service';
describe('SystemMessageTemplateRendererService', () => {
const renderer = new SystemMessageTemplateRendererService();
it('parses exact allowlisted tokens in text order and renders only scalar replacements', () => {
const content = '端口 ${{oldPort}} 已变更为 ${{newPort}}。';
expect(renderer.parse(content, ['oldPort', 'newPort'])).toEqual([
{ kind: 'text', value: '端口 ' },
{ key: 'oldPort', kind: 'variable' },
{ kind: 'text', value: ' 已变更为 ' },
{ key: 'newPort', kind: 'variable' },
{ kind: 'text', value: '。' },
]);
expect(
renderer.render('当前 STUN 端口为 ${{port}},就绪:${{ready}}', {
port: 38213,
ready: true,
}),
).toBe('当前 STUN 端口为 38213就绪true');
});
it.each([
'${{missing}}',
'${{__proto__}}',
'${{prototype}}',
'${{constructor}}',
'${{a.b}}',
'${{a[0]}}',
'${{a()}}',
'${{a + b}}',
'${{nested${{a}}}}',
'${{unclosed',
'extra }}}',
'prefix }} suffix',
])('rejects unsafe or incomplete template syntax %s', (content) => {
expect(() => renderer.validate(content, ['endpoint', 'a'])).toThrow(
expect.objectContaining({
code: 'template_invalid',
}),
);
});
it('counts template content by Unicode code point, including emoji', () => {
expect(() => renderer.validate('😀'.repeat(2_000), [])).not.toThrow();
expect(() => renderer.validate('😀'.repeat(2_001), [])).toThrow(
expect.objectContaining({
code: 'template_invalid',
}),
);
});
it('enforces Unicode code-point limits for variables and rendered output', () => {
expect(() =>
renderer.render('${{value}}', { value: '😀'.repeat(500) }),
).not.toThrow();
expect(() =>
renderer.render('${{value}}', { value: '😀'.repeat(501) }),
).toThrow(
expect.objectContaining({
code: 'template_variable_too_long',
}),
);
const outputTemplate = '${{value}}'.repeat(8);
expect(() =>
renderer.render(outputTemplate, { value: '😀'.repeat(500) }),
).not.toThrow();
expect(() =>
renderer.render(`x${outputTemplate}`, { value: '😀'.repeat(500) }),
).toThrow(
expect.objectContaining({
code: 'rendered_message_too_long',
}),
);
});
it('keeps CQ-looking text literal and replaces multiple tokens without evaluation', () => {
expect(
renderer.render('[CQ:image,file=x] ${{first}}/${{second}}', {
first: 'alpha',
second: 'beta',
}),
).toBe('[CQ:image,file=x] alpha/beta');
});
it('normalizes malformed runtime values to the stable contract error', () => {
expect(() => renderer.validate(42 as never, [])).toThrow(
expect.objectContaining({ code: 'template_invalid' }),
);
expect(() => renderer.render('${{value}}', { value: Number.NaN })).toThrow(
expect.objectContaining({ code: 'template_invalid' }),
);
expect(() => renderer.render('${{value}}', { value: Infinity })).toThrow(
expect.objectContaining({ code: 'template_invalid' }),
);
});
});