mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 日报新增模板市场,支持社区模板安装与使用
This commit is contained in:
@@ -105,7 +105,14 @@ describe('daily report controls', () => {
|
||||
avatar: ''
|
||||
}
|
||||
]
|
||||
}))
|
||||
})),
|
||||
listReportTemplates: vi.fn(async () => []),
|
||||
listReportTemplateCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
catalog: { schemaVersion: '1', status: 'published', templates: [] }
|
||||
})),
|
||||
installReportTemplateFromCatalog: vi.fn(async () => ({ success: true })),
|
||||
uninstallReportTemplate: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -903,4 +910,45 @@ describe('daily report controls', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
|
||||
})
|
||||
|
||||
it('does not select a market template before it is installed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
Object.assign(window.api, {
|
||||
listReportTemplateCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
catalog: {
|
||||
schemaVersion: '1',
|
||||
status: 'published',
|
||||
templates: [
|
||||
{
|
||||
id: 'community.github.example.market',
|
||||
version: '1.0.0',
|
||||
interfaceVersion: '1',
|
||||
name: '市场测试模板',
|
||||
description: '只允许先安装',
|
||||
author: 'fixture',
|
||||
tags: [],
|
||||
license: 'MIT',
|
||||
minAppVersion: null,
|
||||
download: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/packages/community.github.example.market/1.0.0/template.zip',
|
||||
sizeBytes: 1,
|
||||
sha256: 'a'.repeat(64),
|
||||
preview: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/previews/community.github.example.market.png',
|
||||
status: 'published'
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
const marketItem = (await screen.findByText('市场测试模板')).closest('.report-template-item')
|
||||
expect(marketItem).not.toBeNull()
|
||||
await user.click(within(marketItem!).getByRole('button', { name: '查看版式' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '市场测试模板' })
|
||||
expect(within(dialog).getByRole('button', { name: '请先安装' })).toBeDisabled()
|
||||
expect(within(dialog).queryByRole('button', { name: '选择此模板' })).not.toBeInTheDocument()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
test('REPORT-TEMPLATE-MARKET-E2E-01 reads, installs, restores, renders, and switches published templates', async () => {
|
||||
test.setTimeout(120_000)
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-market-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-market-output-'))
|
||||
const launch = (): ReturnType<typeof electron.launch> =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
|
||||
const report = {
|
||||
overview: '虚构远端模板日报概览',
|
||||
hero: {
|
||||
headline: '今日群聊重点',
|
||||
summary: '虚构的结构化日报内容',
|
||||
keyTakeaway: '保留结构化快照并切换版式',
|
||||
pendingNote: '',
|
||||
statusLine: ''
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
title: '模板市场联调',
|
||||
timeRange: '09:00-10:00',
|
||||
heat: '高' as const,
|
||||
participants: ['小明'],
|
||||
summary: '验证安装、重启恢复和导出',
|
||||
keywords: ['模板', '日报'],
|
||||
messages: []
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: {
|
||||
topicHeat: [{ topic: '模板市场联调', score: 1 }],
|
||||
activeTimeline: '09:00-10:00',
|
||||
topSpeakers: [{ name: '小明', count: 1 }],
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: ['模板', '日报'],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
const metadata = {
|
||||
groupName: '虚构市场验收群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'market fixture',
|
||||
heroParticipants: ['小明'],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
let app: Awaited<ReturnType<typeof electron.launch>> | null = null
|
||||
try {
|
||||
app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
|
||||
const catalogResult = await page.evaluate(() => window.api.listReportTemplateCatalog())
|
||||
expect(catalogResult.success, catalogResult.error).toBe(true)
|
||||
const catalog = catalogResult.catalog
|
||||
expect(catalog?.status).toBe('published')
|
||||
expect(catalog?.templates).toHaveLength(3)
|
||||
const sourceCommit = catalog?.source?.commit
|
||||
expect(sourceCommit).toMatch(/^[a-f0-9]{40}$/)
|
||||
const entries = catalog!.templates
|
||||
expect(new Set(entries.map((entry) => entry.id))).toEqual(
|
||||
new Set([
|
||||
'community.github.tracememo.quickread',
|
||||
'community.github.tracememo.paperdaily',
|
||||
'community.github.tracememo.teamboard'
|
||||
])
|
||||
)
|
||||
for (const entry of entries) {
|
||||
expect(entry.interfaceVersion).toBe('1')
|
||||
expect(entry.status).toBe('published')
|
||||
expect(entry.download).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
expect(entry.preview).toContain(`/Wxw-Gu/TraceMemo-Templates/${sourceCommit}/`)
|
||||
}
|
||||
|
||||
const exportsById = new Map<
|
||||
string,
|
||||
{ htmlPath: string; pngPath: string; imageDataUrl: string; version: string }
|
||||
>()
|
||||
for (const entry of entries) {
|
||||
const installed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.installReportTemplateFromCatalog(id, version),
|
||||
{ id: entry.id, version: entry.version }
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
expect(installed.catalogEntry?.sha256).toBe(entry.sha256)
|
||||
expect(installed.template?.id).toBe(entry.id)
|
||||
expect(installed.template?.version).toBe(entry.version)
|
||||
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: entry.id, version: entry.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
expect(exported.htmlPath).toBeTruthy()
|
||||
expect(exported.pngPath).toBeTruthy()
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(entry.platform === 'desktop' ? 1440 : 430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
exportsById.set(entry.id, {
|
||||
htmlPath: exported.htmlPath!,
|
||||
pngPath: exported.pngPath!,
|
||||
imageDataUrl: exported.imageDataUrl!,
|
||||
version: entry.version
|
||||
})
|
||||
}
|
||||
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const restored = await page.evaluate(() => window.api.listReportTemplates())
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
restored.some((template) => template.id === entry.id && template.version === entry.version)
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const quickread = entries.find((entry) => entry.id.endsWith('.quickread'))!
|
||||
const paperdaily = entries.find((entry) => entry.id.endsWith('.paperdaily'))!
|
||||
const firstExport = exportsById.get(quickread.id)!
|
||||
const saved = await page.evaluate(
|
||||
async ({ firstExportValue, reportValue, metadataValue, templateId }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-market-group',
|
||||
contactName: metadataValue.groupName,
|
||||
source: 'manual',
|
||||
dateRange: metadataValue.dateRange,
|
||||
messageCount: metadataValue.messageCount,
|
||||
generatedAt: '2026-09-07T10:00:00.000Z',
|
||||
reportDate: metadataValue.reportDate,
|
||||
generatedImage: firstExportValue.imageDataUrl,
|
||||
htmlPath: firstExportValue.htmlPath,
|
||||
pngPath: firstExportValue.pngPath,
|
||||
reportSnapshot: reportValue,
|
||||
reportMetadata: metadataValue,
|
||||
templateId
|
||||
}),
|
||||
{
|
||||
firstExportValue: firstExport,
|
||||
reportValue: report,
|
||||
metadataValue: metadata,
|
||||
templateId: `external:${quickread.id}@${quickread.version}`
|
||||
}
|
||||
)
|
||||
expect(saved.success, saved.error).toBe(true)
|
||||
const reportId = saved.record!.id
|
||||
|
||||
const switchedExport = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(switchedExport.success, switchedExport.error).toBe(true)
|
||||
const switched = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, exported }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: exported.imageDataUrl,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: reportId,
|
||||
id: paperdaily.id,
|
||||
version: paperdaily.version,
|
||||
exported: {
|
||||
imageDataUrl: switchedExport.imageDataUrl!,
|
||||
htmlPath: switchedExport.htmlPath!,
|
||||
pngPath: switchedExport.pngPath!
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(switched.success, switched.error).toBe(true)
|
||||
expect(switched.record?.id).toBe(reportId)
|
||||
expect(switched.record?.templateId).toBe(`external:${paperdaily.id}@${paperdaily.version}`)
|
||||
expect(switched.record?.reportSnapshot).toEqual(report)
|
||||
expect(readFileSync(switched.record!.htmlPath!, 'utf8')).toContain('虚构的结构化日报内容')
|
||||
|
||||
const history = await page.evaluate(() => window.api.listGeneratedReports())
|
||||
expect(history.success).toBe(true)
|
||||
expect(history.reports?.find((record) => record.id === reportId)?.templateId).toBe(
|
||||
`external:${paperdaily.id}@${paperdaily.version}`
|
||||
)
|
||||
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: paperdaily.id, version: paperdaily.version }
|
||||
)
|
||||
expect(removed.success, removed.error).toBe(true)
|
||||
const afterUninstall = await page.evaluate(
|
||||
async ({ id, version, reportValue, metadataValue }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
report: reportValue,
|
||||
metadata: metadataValue
|
||||
}),
|
||||
{ id: paperdaily.id, version: paperdaily.version, reportValue: report, metadataValue: metadata }
|
||||
)
|
||||
expect(afterUninstall.success).toBe(false)
|
||||
expect(afterUninstall.error).toContain('模板不存在')
|
||||
|
||||
const legacyHtml = join(outputDir, 'legacy-without-structured-data.html')
|
||||
writeFileSync(legacyHtml, '<!doctype html><html><body><h1>旧日报</h1></body></html>')
|
||||
const legacy = await page.evaluate(
|
||||
async ({ htmlPath, pngPath, imageDataUrl }) =>
|
||||
window.api.saveGeneratedReport({
|
||||
contactId: 'fixture-legacy-group',
|
||||
contactName: '虚构旧日报',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
generatedAt: '2026-09-07T11:00:00.000Z',
|
||||
htmlPath,
|
||||
pngPath,
|
||||
generatedImage: imageDataUrl
|
||||
}),
|
||||
{ htmlPath: legacyHtml, pngPath: firstExport.pngPath, imageDataUrl: firstExport.imageDataUrl }
|
||||
)
|
||||
expect(legacy.success).toBe(true)
|
||||
const legacySwitch = await page.evaluate(
|
||||
async ({ reportIdValue, id, version, imageDataUrl, htmlPath }) =>
|
||||
window.api.updateGeneratedReportTemplate({
|
||||
reportId: reportIdValue,
|
||||
templateId: `external:${id}@${version}`,
|
||||
templateRef: { id, version },
|
||||
generatedImage: imageDataUrl,
|
||||
htmlPath,
|
||||
pngPath: htmlPath
|
||||
}),
|
||||
{
|
||||
reportIdValue: legacy.record!.id,
|
||||
id: quickread.id,
|
||||
version: quickread.version,
|
||||
imageDataUrl: firstExport.imageDataUrl,
|
||||
htmlPath: legacyHtml
|
||||
}
|
||||
)
|
||||
expect(legacySwitch.success).toBe(false)
|
||||
expect(legacySwitch.error).toContain('旧报告未保存结构化数据')
|
||||
} finally {
|
||||
if (app) await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,600 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
import { createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
const makeZip = async (
|
||||
directory: string,
|
||||
files: Record<string, string | Buffer>
|
||||
): Promise<string> => {
|
||||
const zipPath = join(directory, `${Math.random().toString(36).slice(2)}.zip`)
|
||||
const output = createWriteStream(zipPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
archive.pipe(output)
|
||||
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
output.on('close', resolvePromise)
|
||||
output.on('error', reject)
|
||||
archive.on('error', reject)
|
||||
void archive.finalize().catch(reject)
|
||||
})
|
||||
return zipPath
|
||||
}
|
||||
|
||||
const templateManifest = (patch: Record<string, unknown> = {}): string =>
|
||||
JSON.stringify({
|
||||
protocolVersion: '1.0',
|
||||
kind: 'daily-report',
|
||||
id: 'community.github.example.runtime-attack',
|
||||
name: '运行时安全测试模板',
|
||||
author: { name: 'fixture' },
|
||||
templateVersion: '1.0.0',
|
||||
interfaceVersion: '1',
|
||||
entry: 'template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20000 },
|
||||
license: { spdx: 'MIT' },
|
||||
...patch
|
||||
})
|
||||
|
||||
const runtimeAttackReport = (imageUrl: string) => ({
|
||||
overview: '虚构运行时安全测试',
|
||||
topics: [
|
||||
{
|
||||
title: '攻击 fixture',
|
||||
timeRange: '10:00-10:01',
|
||||
heat: '高' as const,
|
||||
participants: [],
|
||||
summary: '用于触发报告窗口资源请求',
|
||||
keywords: [],
|
||||
image: { imageUrl, note: 'fixture' }
|
||||
}
|
||||
],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
})
|
||||
|
||||
const runtimeAttackMetadata = {
|
||||
groupName: '虚构攻击测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture security probe',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
}
|
||||
|
||||
const startProbeServer = async (): Promise<{
|
||||
server: ReturnType<typeof createServer>
|
||||
port: number
|
||||
httpRequests: string[]
|
||||
wsRequests: string[]
|
||||
}> => {
|
||||
const httpRequests: string[] = []
|
||||
const wsRequests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
httpRequests.push(request.url || '')
|
||||
response.statusCode = 200
|
||||
response.end('unexpected fixture response')
|
||||
})
|
||||
server.on('upgrade', (request, socket) => {
|
||||
wsRequests.push(request.url || '')
|
||||
socket.destroy()
|
||||
})
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
server.off('listening', onListening)
|
||||
reject(error)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
server.off('error', onError)
|
||||
resolvePromise()
|
||||
}
|
||||
server.once('error', onError)
|
||||
server.once('listening', onListening)
|
||||
server.listen(0, '127.0.0.1')
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('本地安全探针监听器未分配端口')
|
||||
}
|
||||
return { server, port: address.port, httpRequests, wsRequests }
|
||||
}
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-01 installs and renders a fixture without author code', async ({}, testInfo) => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-output-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const installed = await page.evaluate(
|
||||
async (p) => window.api.installReportTemplate(p),
|
||||
resolve('examples/report-template-basic.zip')
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.basic-feed')
|
||||
expect(installed.template?.version).toBe('1.0.0')
|
||||
await app.close()
|
||||
app = await launch()
|
||||
page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const reloaded = await page.evaluate(() => window.api.listReportTemplates())
|
||||
expect(
|
||||
reloaded.some(
|
||||
(item) => item.id === installed.template?.id && item.version === installed.template?.version
|
||||
)
|
||||
).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构测试群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 3,
|
||||
activeUsers: 2,
|
||||
timeSpan: '09:00-10:00',
|
||||
generatedAt: '2026-09-07 10:00',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'fixture export',
|
||||
heroParticipants: [],
|
||||
avatars: []
|
||||
},
|
||||
report: {
|
||||
overview: '虚构日报概览',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('Content-Security-Policy')
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.length).toBeGreaterThan(1000)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBeGreaterThan(100)
|
||||
await testInfo.attach('report-png', { path: exported.pngPath!, contentType: 'image/png' })
|
||||
const removed = await page.evaluate(
|
||||
async ({ id, version }) => window.api.uninstallReportTemplate(id, version),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(removed.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构日报概览')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-CAPTURE-01 enforces manifest maxHeight in the production capture path', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-capture-fixture-'))
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const packagePath = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({
|
||||
id: 'community.github.example.capture-cap',
|
||||
name: '截图高度限制测试',
|
||||
templateVersion: '1.0.0',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 800 }
|
||||
}),
|
||||
'template.html': `<!doctype html><html><head><style>html,body{margin:0;min-height:3000px}</style></head><body><h1>{{REPORT_TITLE}}</h1></body></html>`
|
||||
})
|
||||
const installed = await page.evaluate(
|
||||
async (path) => window.api.installReportTemplate(path),
|
||||
packagePath
|
||||
)
|
||||
expect(installed.success, installed.error).toBe(true)
|
||||
const exported = await page.evaluate(
|
||||
async ({ id, version }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata: {
|
||||
groupName: '虚构截图限制群',
|
||||
reportDate: '2026-09-07',
|
||||
dateRange: '今日',
|
||||
messageCount: 1,
|
||||
activeUsers: 1,
|
||||
timeSpan: '10:00-10:01',
|
||||
generatedAt: '2026-09-07 10:01',
|
||||
recordNote: 'fixture',
|
||||
footerNote: 'capture fixture',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
},
|
||||
report: {
|
||||
overview: '虚构截图高度限制',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: { topicHeat: [], activeTimeline: '', topSpeakers: [], voiceLeaderboard: [] },
|
||||
keywords: [],
|
||||
media: { gallery: [], voiceHighlights: [], funBadges: [] }
|
||||
}
|
||||
}),
|
||||
{ id: installed.template!.id, version: installed.template!.version }
|
||||
)
|
||||
expect(exported.success, exported.error).toBe(true)
|
||||
const png = readFileSync(exported.pngPath!)
|
||||
expect(png.readUInt32BE(16)).toBe(430)
|
||||
expect(png.readUInt32BE(20)).toBe(800)
|
||||
} finally {
|
||||
await app.close()
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-TEMPLATE-E2E-SECURITY-01 blocks runtime attacks in a real Electron report window', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-template-security-user-'))
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-output-'))
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-template-security-fixture-'))
|
||||
const securityLog = join(outputDir, 'security-blocks.ndjson')
|
||||
const probeServer = await startProbeServer()
|
||||
const outsideImage = join(userData, 'outside.png')
|
||||
writeFileSync(
|
||||
outsideImage,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
)
|
||||
)
|
||||
const launch = () =>
|
||||
electron.launch({
|
||||
args: [resolve('out/main/reportTemplateTest.js')],
|
||||
env: {
|
||||
...process.env,
|
||||
TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData,
|
||||
TRACEMEMO_REPORT_OUTPUT_DIR: outputDir,
|
||||
TRACEMEMO_TEMPLATE_SECURITY_LOG: securityLog
|
||||
}
|
||||
})
|
||||
let app = await launch()
|
||||
let page = await app.firstWindow()
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
try {
|
||||
const runtimeZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest(),
|
||||
'template.html': `<!doctype html><html><head><style>body{font-family:sans-serif;padding:24px}</style></head><body><h1>{{REPORT_TITLE}}</h1><main>{{TOPIC_CARDS}}</main></body></html>`
|
||||
})
|
||||
const maliciousZip = await makeZip(fixtureDir, {
|
||||
'manifest.json': templateManifest({ templateVersion: '1.0.1' }),
|
||||
'template.html': '<script>window.__templateAuthorCode = true</script>'
|
||||
})
|
||||
|
||||
const rejected = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
maliciousZip
|
||||
)
|
||||
expect(rejected.success).toBe(false)
|
||||
expect(rejected.code).toBe('unsafe_html')
|
||||
|
||||
const installed = await page.evaluate(
|
||||
async (packagePath) => window.api.installReportTemplate(packagePath),
|
||||
runtimeZip
|
||||
)
|
||||
expect(installed.success).toBe(true)
|
||||
expect(installed.template?.id).toBe('community.github.example.runtime-attack')
|
||||
|
||||
const outsideFileUrl = pathToFileURL(outsideImage).toString()
|
||||
const targets = {
|
||||
http: `http://127.0.0.1:${probeServer.port}/http-probe`,
|
||||
https: `https://127.0.0.1:${probeServer.port}/https-probe`,
|
||||
ws: `ws://127.0.0.1:${probeServer.port}/ws-probe`,
|
||||
wss: `wss://127.0.0.1:${probeServer.port}/wss-probe`
|
||||
}
|
||||
await app.evaluate(
|
||||
({ app: electronApp }, { fileUrl, targets }) => {
|
||||
const state = {
|
||||
hiddenWindows: 0,
|
||||
createdWindows: 0,
|
||||
navigations: [] as string[],
|
||||
childWindowUrls: [] as string[],
|
||||
probeStarted: false,
|
||||
probeDone: false,
|
||||
probe: undefined as
|
||||
| {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
;(
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: typeof state }
|
||||
).__tmTemplateSecurity = state
|
||||
electronApp.on('browser-window-created', (_event, browserWindow) => {
|
||||
state.createdWindows += 1
|
||||
if (browserWindow.isVisible()) return
|
||||
state.hiddenWindows += 1
|
||||
browserWindow.webContents.on('will-navigate', (_navigationEvent, url) => {
|
||||
state.navigations.push(url)
|
||||
})
|
||||
browserWindow.webContents.on('did-create-window', (_childWindow, details) => {
|
||||
state.childWindowUrls.push(details.url)
|
||||
})
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.probeStarted) return
|
||||
state.probeStarted = true
|
||||
const serializedFileUrl = JSON.stringify(fileUrl)
|
||||
const serializedTargets = JSON.stringify(targets)
|
||||
const requestViaSession = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
await Promise.race([
|
||||
browserWindow.webContents.session.fetch(url, { cache: 'no-store' }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('network probe timeout')), 2000)
|
||||
)
|
||||
])
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
const networkProbe = Promise.all([
|
||||
requestViaSession(targets.http),
|
||||
requestViaSession(targets.https),
|
||||
requestViaSession(targets.ws),
|
||||
requestViaSession(targets.wss),
|
||||
requestViaSession(fileUrl)
|
||||
]).then(([http, https, ws, wss, localResource]) => ({
|
||||
http,
|
||||
https,
|
||||
ws,
|
||||
wss,
|
||||
localResource
|
||||
}))
|
||||
const rendererProbe = browserWindow.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const targets = ${serializedTargets}
|
||||
const originalUrl = location.href
|
||||
const rejectedSocket = (url) => new Promise((resolve) => {
|
||||
let settled = false
|
||||
let socket
|
||||
const finish = (rejected) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket?.close()
|
||||
resolve(rejected)
|
||||
}
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
socket.onerror = () => finish(true)
|
||||
socket.onopen = () => finish(false)
|
||||
setTimeout(() => finish(true), 1000)
|
||||
} catch {
|
||||
finish(true)
|
||||
}
|
||||
})
|
||||
const rejectedLocalImage = new Promise((resolve) => {
|
||||
const outside = new Image()
|
||||
outside.onload = () => resolve(outside.naturalWidth === 0)
|
||||
outside.onerror = () => resolve(true)
|
||||
setTimeout(() => resolve(false), 500)
|
||||
outside.src = ${serializedFileUrl}
|
||||
})
|
||||
const windowOpen = window.open(targets.https + '/new-window') === null
|
||||
const target = document.createElement('a')
|
||||
target.href = targets.https + '/target-blank'
|
||||
target.target = '_blank'
|
||||
target.textContent = 'open'
|
||||
document.body.append(target)
|
||||
const targetBlankClicked = target.target === '_blank' && target.href === targets.https + '/target-blank'
|
||||
target.click()
|
||||
target.remove()
|
||||
try { location.assign(targets.ws + '/renderer-navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
try { location.assign(targets.https + '/navigation') } catch { /* will-navigate 事件会阻止导航 */ }
|
||||
return Promise.all([
|
||||
rejectedLocalImage,
|
||||
rejectedSocket(targets.ws + '/renderer-probe'),
|
||||
rejectedSocket(targets.wss + '/renderer-probe'),
|
||||
new Promise((resolve) => setTimeout(resolve, 100))
|
||||
]).then(([localResource, rendererWs, rendererWss]) => ({ localResource, rendererWs, rendererWss, windowOpen, targetBlankClicked, navigation: location.href === originalUrl }))
|
||||
})()`
|
||||
)
|
||||
void Promise.all([networkProbe, rendererProbe])
|
||||
.then(([network, result]) => {
|
||||
const values = result as {
|
||||
localResource: boolean
|
||||
rendererWs: boolean
|
||||
rendererWss: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
state.probe = {
|
||||
...network,
|
||||
localResource: values.localResource,
|
||||
windowOpen: values.windowOpen,
|
||||
navigation: values.navigation,
|
||||
ws: network.ws || values.rendererWs,
|
||||
wss: network.wss || values.rendererWss,
|
||||
targetBlankClicked: values.targetBlankClicked,
|
||||
targetBlank:
|
||||
values.targetBlankClicked &&
|
||||
state.createdWindows === 1 &&
|
||||
!state.childWindowUrls.includes(`${targets.https}/target-blank`)
|
||||
}
|
||||
state.probeDone = true
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
})
|
||||
},
|
||||
{ fileUrl: outsideFileUrl, targets }
|
||||
)
|
||||
|
||||
const reportWindowPromise = app.waitForEvent('window', {
|
||||
predicate: (candidate) => candidate !== page
|
||||
})
|
||||
const exportPromise = page.evaluate(
|
||||
async ({ id, version, metadata, report }) =>
|
||||
window.api.exportGroupReport({
|
||||
templateRef: { id, version },
|
||||
metadata,
|
||||
report
|
||||
}),
|
||||
{
|
||||
id: installed.template!.id,
|
||||
version: installed.template!.version,
|
||||
metadata: runtimeAttackMetadata,
|
||||
report: runtimeAttackReport(`${targets.https}/render-image`)
|
||||
}
|
||||
)
|
||||
await reportWindowPromise
|
||||
const exported = await exportPromise
|
||||
expect(exported.success).toBe(true)
|
||||
expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('虚构攻击测试群日报')
|
||||
expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & { __tmTemplateSecurity?: { probeDone: boolean } }
|
||||
).__tmTemplateSecurity
|
||||
return state?.probeDone ?? false
|
||||
}),
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const securityLogLines = readFileSync(securityLog, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { url: string; reason: string })
|
||||
const blockedUrls = securityLogLines.map((entry) => entry.url)
|
||||
for (const url of [targets.http, targets.https, targets.ws, targets.wss, outsideFileUrl]) {
|
||||
expect(blockedUrls, `缺少运行时拦截记录: ${url}`).toContain(url)
|
||||
const block = securityLogLines.find((entry) => entry.url === url)
|
||||
expect(block?.reason, `运行时拦截 reason 缺失: ${url}`).toBe(
|
||||
url.startsWith('file:') ? 'file-path-not-allowed' : 'scheme-not-allowed'
|
||||
)
|
||||
}
|
||||
expect(probeServer.httpRequests).toEqual([])
|
||||
expect(probeServer.wsRequests).toEqual([])
|
||||
|
||||
const securityState = await app.evaluate(() => {
|
||||
const state = (
|
||||
globalThis as typeof globalThis & {
|
||||
__tmTemplateSecurity?: {
|
||||
hiddenWindows: number
|
||||
navigations: string[]
|
||||
createdWindows: number
|
||||
childWindowUrls: string[]
|
||||
probeDone: boolean
|
||||
probe?: {
|
||||
http: boolean
|
||||
https: boolean
|
||||
ws: boolean
|
||||
wss: boolean
|
||||
localResource: boolean
|
||||
windowOpen: boolean
|
||||
targetBlankClicked: boolean
|
||||
targetBlank: boolean
|
||||
navigation: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
).__tmTemplateSecurity
|
||||
return state
|
||||
})
|
||||
expect(securityState?.hiddenWindows).toBe(1)
|
||||
expect(securityState?.createdWindows).toBe(1)
|
||||
expect(securityState?.childWindowUrls).toEqual([])
|
||||
expect(securityState?.navigations).toContain(`${targets.https}/navigation`)
|
||||
expect(securityState?.probe, JSON.stringify(securityState)).toEqual({
|
||||
http: true,
|
||||
https: true,
|
||||
ws: true,
|
||||
wss: true,
|
||||
localResource: true,
|
||||
windowOpen: true,
|
||||
targetBlankClicked: true,
|
||||
targetBlank: true,
|
||||
navigation: true
|
||||
})
|
||||
expect(app.windows().length).toBe(1)
|
||||
} finally {
|
||||
await app.close()
|
||||
if (probeServer.server.listening) {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
probeServer.server.close((error) => (error ? reject(error) : resolvePromise()))
|
||||
})
|
||||
}
|
||||
rmSync(fixtureDir, { recursive: true, force: true })
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -245,6 +245,28 @@ describe('Local API authentication', () => {
|
||||
expect(fixture.testSend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects external template refs on the legacy report HTTP API', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${VALID_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
report: {},
|
||||
metadata: {},
|
||||
templateRef: { id: 'community.github.example.template', version: '1.0.0' }
|
||||
})
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 400,
|
||||
error: 'HTTP API 暂不支持外部日报模板,请使用内置 templateId',
|
||||
details: { code: 'external_template_unsupported' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://localhost',
|
||||
'http://localhost:5173',
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tempDirectory: '',
|
||||
fetch: vi.fn(),
|
||||
install: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: (name: string) => (name === 'temp' ? mocks.tempDirectory : mocks.tempDirectory)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/report-template-service', () => ({
|
||||
reportTemplateService: {
|
||||
install: mocks.install
|
||||
}
|
||||
}))
|
||||
|
||||
import { ReportTemplateMarketService } from '../../src/main/report-template-market-service'
|
||||
import { REPORT_TEMPLATE_CATALOG_URL } from '../../src/shared/report-template-market'
|
||||
import {
|
||||
decodeExternalReportTemplateId,
|
||||
encodeExternalReportTemplateId
|
||||
} from '../../src/shared/report-templates'
|
||||
|
||||
const commit = 'a'.repeat(40)
|
||||
const packageBytes = Buffer.from('fixture-template-package')
|
||||
const packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex')
|
||||
const templateId = 'community.github.tracememo.quickread'
|
||||
const templateVersion = '1.0.0'
|
||||
const downloadUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
|
||||
const previewUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/previews/${templateId}/${templateVersion}.png`
|
||||
|
||||
const catalog = (
|
||||
patch: Record<string, unknown> = {}
|
||||
): {
|
||||
schemaVersion: string
|
||||
generatedAt: string
|
||||
source: { repository: string; commit: string }
|
||||
status: string
|
||||
templates: Array<Record<string, unknown>>
|
||||
} => ({
|
||||
schemaVersion: '1',
|
||||
generatedAt: '2026-09-07T08:08:13Z',
|
||||
source: { repository: 'Wxw-Gu/TraceMemo-Templates', commit },
|
||||
status: 'published',
|
||||
templates: [
|
||||
{
|
||||
id: templateId,
|
||||
version: templateVersion,
|
||||
interfaceVersion: '1',
|
||||
name: '极简速读',
|
||||
description: '虚构模板目录条目',
|
||||
author: 'fixture',
|
||||
platform: 'mobile',
|
||||
tags: ['fixture'],
|
||||
license: 'MIT',
|
||||
minAppVersion: null,
|
||||
download: downloadUrl,
|
||||
sizeBytes: packageBytes.length,
|
||||
sha256: packageSha256,
|
||||
preview: previewUrl,
|
||||
publishedAt: '2026-09-07T08:08:13Z',
|
||||
status: 'published'
|
||||
}
|
||||
],
|
||||
...patch
|
||||
})
|
||||
|
||||
const responseFor = (body: Buffer | string, status = 200): Response =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { 'content-length': String(Buffer.byteLength(body)) }
|
||||
})
|
||||
|
||||
const installResult = {
|
||||
id: templateId,
|
||||
version: templateVersion,
|
||||
interfaceVersion: '1',
|
||||
source: 'installed' as const,
|
||||
name: '极简速读',
|
||||
author: 'fixture',
|
||||
entryPath: '/tmp/fixture-template/template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20_000 },
|
||||
license: { spdx: 'MIT' }
|
||||
}
|
||||
|
||||
describe('ReportTemplateMarketService', () => {
|
||||
let service: ReportTemplateMarketService
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.tempDirectory = await mkdtemp(join(tmpdir(), 'tracememo-template-market-test-'))
|
||||
service = new ReportTemplateMarketService()
|
||||
mocks.fetch.mockReset()
|
||||
mocks.install.mockReset()
|
||||
vi.stubGlobal('fetch', mocks.fetch)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await rm(mocks.tempDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('loads and normalizes a published catalog entry from the fixed raw URL', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result).toEqual({ success: true, catalog: catalog() })
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(mocks.fetch).toHaveBeenCalledWith(
|
||||
new URL(REPORT_TEMPLATE_CATALOG_URL),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json, application/zip, image/png',
|
||||
'User-Agent': 'TraceMemo'
|
||||
}),
|
||||
redirect: 'error'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects incompatible interfaces and non-raw download URLs before install', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], interfaceVersion: '2' }] })))
|
||||
)
|
||||
|
||||
const incompatible = await service.listCatalog()
|
||||
|
||||
expect(incompatible.success).toBe(false)
|
||||
expect(incompatible.code).toBe('unsupported_interface')
|
||||
expect(incompatible.error).toContain('模板接口版本不兼容')
|
||||
|
||||
mocks.fetch.mockReset()
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], download: 'https://evil.example/template.zip' }] })))
|
||||
)
|
||||
|
||||
const invalidUrl = await service.listCatalog()
|
||||
|
||||
expect(invalidUrl.success).toBe(false)
|
||||
expect(invalidUrl.code).toBe('invalid_catalog')
|
||||
expect(invalidUrl.error).toContain('只允许 GitHub raw HTTPS 地址')
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a catalog URL from another raw GitHub repository', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(
|
||||
JSON.stringify(
|
||||
catalog({
|
||||
templates: [
|
||||
{
|
||||
...catalog().templates[0],
|
||||
download: `https://raw.githubusercontent.com/another-owner/another-repo/${commit}/template.zip`
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.code).toBe('invalid_catalog')
|
||||
expect(result.error).toContain('只允许 GitHub raw HTTPS 地址')
|
||||
})
|
||||
|
||||
it('rejects package references that are not pinned to the catalog source commit', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(
|
||||
responseFor(
|
||||
JSON.stringify(
|
||||
catalog({
|
||||
templates: [
|
||||
{
|
||||
...catalog().templates[0],
|
||||
download: `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${'b'.repeat(40)}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const result = await service.listCatalog()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.code).toBe('invalid_catalog')
|
||||
expect(result.error).toContain('未固定到目录 source.commit')
|
||||
})
|
||||
|
||||
it('reports a missing catalog version without downloading a package', async () => {
|
||||
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
|
||||
const result = await service.installFromCatalog(templateId, '9.9.9')
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
code: 'catalog_template_not_found',
|
||||
error: `远端目录不存在:${templateId}@9.9.9`
|
||||
})
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a package when downloaded bytes do not match catalog size or SHA-256', async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
.mockResolvedValueOnce(responseFor(Buffer.from('tampered-package')))
|
||||
|
||||
const result = await service.installFromCatalog(templateId, templateVersion)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
code: 'download_integrity_failed',
|
||||
catalogEntry: expect.objectContaining({ id: templateId, version: templateVersion })
|
||||
})
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes a verified package to a temporary path, installs it, and removes the temporary directory', async () => {
|
||||
mocks.fetch
|
||||
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
|
||||
.mockResolvedValueOnce(responseFor(packageBytes))
|
||||
let packagePath = ''
|
||||
mocks.install.mockImplementationOnce(async (candidatePath: string) => {
|
||||
packagePath = candidatePath
|
||||
await expect(readFile(candidatePath)).resolves.toEqual(packageBytes)
|
||||
return installResult
|
||||
})
|
||||
|
||||
const result = await service.installFromCatalog(templateId, templateVersion)
|
||||
|
||||
expect(result).toEqual({ success: true, template: installResult, catalogEntry: catalog().templates[0] })
|
||||
expect(mocks.install).toHaveBeenCalledOnce()
|
||||
expect(packagePath).toContain(`${templateId}-${templateVersion}.zip`)
|
||||
await expect(access(packagePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('external report template selection keys', () => {
|
||||
it('round-trips IDs and versions without collapsing them into built-in IDs', () => {
|
||||
const selectionId = encodeExternalReportTemplateId(templateId, templateVersion)
|
||||
|
||||
expect(selectionId).toBe(`external:${templateId}@${templateVersion}`)
|
||||
expect(decodeExternalReportTemplateId(selectionId)).toEqual({
|
||||
id: templateId,
|
||||
version: templateVersion
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed, incomplete, or non-string external keys', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'mobile-feed',
|
||||
'external:',
|
||||
'external:community.github.example@',
|
||||
'external:@1.0.0',
|
||||
'external:community.github.example@1.0.0@extra',
|
||||
'external:community.github.example'
|
||||
]) {
|
||||
expect(decodeExternalReportTemplateId(value)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ZipArchive } from 'archiver'
|
||||
|
||||
const mockPaths = vi.hoisted(() => ({ userData: '' }))
|
||||
vi.mock('electron', () => ({ app: { getPath: () => mockPaths.userData } }))
|
||||
const userData = await mkdtemp(join(tmpdir(), 'tracememo-template-service-'))
|
||||
mockPaths.userData = userData
|
||||
|
||||
import { ReportTemplateError, REPORT_TEMPLATE_LIMITS } from '../../src/shared/report-template-package'
|
||||
import { ReportTemplateService } from '../../src/main/report-template-service'
|
||||
|
||||
async function makeZip(files: Record<string, string | Buffer>): Promise<string> {
|
||||
const zipPath = join(userData, `${Math.random().toString(36).slice(2)}.zip`)
|
||||
const output = (await import('node:fs')).createWriteStream(zipPath)
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
archive.pipe(output)
|
||||
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
|
||||
await archive.finalize()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.on('close', () => resolve())
|
||||
output.on('error', reject)
|
||||
})
|
||||
return zipPath
|
||||
}
|
||||
|
||||
const manifest = (patch: Record<string, unknown> = {}): string => JSON.stringify({
|
||||
protocolVersion: '1.0', kind: 'daily-report', id: 'community.github.example.basic-feed', name: '示例',
|
||||
author: { name: 'example' }, templateVersion: '1.0.0', interfaceVersion: '1', entry: 'template.html',
|
||||
capture: { width: 430, maxWidth: 430, maxHeight: 20000 }, license: { spdx: 'MIT' }, ...patch
|
||||
})
|
||||
|
||||
describe('ReportTemplateService', () => {
|
||||
let service: ReportTemplateService
|
||||
beforeEach(() => { service = new ReportTemplateService() })
|
||||
afterEach(async () => { await rm(join(userData, 'report-templates'), { recursive: true, force: true }) })
|
||||
|
||||
it('installs, lists, reloads and uninstalls a valid package', async () => {
|
||||
const zip = await makeZip({ 'manifest.json': manifest(), 'template.html': '<!doctype html><html><head><style>.x{color:red}</style></head><body><h1 class="{{TOPICS_EMPTY_CLASS}}">{{REPORT_TITLE}}</h1></body></html>' })
|
||||
const installed = await service.install(zip)
|
||||
expect(installed.source).toBe('installed')
|
||||
expect((await service.list()).some((item) => item.id === installed.id)).toBe(true)
|
||||
const reloaded = new ReportTemplateService()
|
||||
expect((await reloaded.list()).find((item) => item.id === installed.id)?.version).toBe('1.0.0')
|
||||
await service.uninstall(installed.id, installed.version)
|
||||
expect((await service.list()).some((item) => item.id === installed.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing or incompatible manifest fields', async () => {
|
||||
const missing = await makeZip({ 'manifest.json': '{}', 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(missing)).rejects.toMatchObject({ code: 'unsupported_protocol' })
|
||||
const incompatible = await makeZip({ 'manifest.json': manifest({ interfaceVersion: '2' }), 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(incompatible)).rejects.toMatchObject({ code: 'unsupported_interface' })
|
||||
})
|
||||
|
||||
it('rejects dangerous HTML, invalid interpolation and duplicate paths', async () => {
|
||||
const dangerous = await makeZip({ 'manifest.json': manifest(), 'template.html': '<script>alert(1)</script>' })
|
||||
await expect(service.install(dangerous)).rejects.toBeInstanceOf(ReportTemplateError)
|
||||
const attr = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{REPORT_TITLE}}"></div>' })
|
||||
await expect(service.install(attr)).rejects.toMatchObject({ code: 'invalid_placeholder_context' })
|
||||
const css = await makeZip({ 'manifest.json': manifest(), 'template.html': '<style>.x{background:url(https://evil.test/a.png)}</style>' })
|
||||
await expect(service.install(css)).rejects.toMatchObject({ code: 'unsafe_url' })
|
||||
const missingAsset = await makeZip({ 'manifest.json': manifest(), 'template.html': '<img src="assets/missing.png">' })
|
||||
await expect(service.install(missingAsset)).rejects.toMatchObject({ code: 'missing_asset' })
|
||||
const validKinds = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{TOPICS_EMPTY_CLASS}}">{{TOPICS_MORE_NOTE}}</div>' })
|
||||
await expect(service.install(validKinds)).resolves.toMatchObject({ id: 'community.github.example.basic-feed' })
|
||||
const duplicate = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>x</p>', 'TEMPLATE.HTML': '<p>y</p>' })
|
||||
await expect(service.install(duplicate)).rejects.toMatchObject({ code: 'duplicate_entry' })
|
||||
})
|
||||
|
||||
it('rejects traversal and oversized packages before staging output', async () => {
|
||||
const traversal = await makeZip({ 'manifest.json': manifest(), '../escape.txt': 'x', 'template.html': '<p>x</p>' })
|
||||
await expect(service.install(traversal)).rejects.toBeInstanceOf(ReportTemplateError)
|
||||
const oversized = await makeZip({ 'manifest.json': manifest(), 'template.html': Buffer.alloc(REPORT_TEMPLATE_LIMITS.maxFileBytes + 1) })
|
||||
await expect(service.install(oversized)).rejects.toMatchObject({ code: 'file_too_large' })
|
||||
await expect(readFile(join(userData, 'escape.txt'))).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an uninstall reference that could escape the registry directory', async () => {
|
||||
await expect(service.uninstall('../outside', '1.0.0')).rejects.toMatchObject({
|
||||
code: 'invalid_template_ref'
|
||||
})
|
||||
await expect(
|
||||
service.uninstall('community.github.example.basic-feed', '../outside')
|
||||
).rejects.toMatchObject({ code: 'invalid_template_ref' })
|
||||
})
|
||||
|
||||
it('rejects external refs that target built-in templates', async () => {
|
||||
await expect(service.resolve({ id: 'v1', version: '1.0.0' })).rejects.toMatchObject({
|
||||
code: 'builtin_template_ref'
|
||||
})
|
||||
})
|
||||
|
||||
it('checks an expected catalog identity before staging a package', async () => {
|
||||
const zip = await makeZip({
|
||||
'manifest.json': manifest(),
|
||||
'template.html': '<p>{{REPORT_TITLE}}</p>'
|
||||
})
|
||||
await expect(
|
||||
service.install(zip, {
|
||||
id: 'community.github.example.other-template',
|
||||
version: '1.0.0'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'catalog_manifest_mismatch' })
|
||||
})
|
||||
|
||||
it('is idempotent for identical versions and rejects content conflicts', async () => {
|
||||
const first = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>a</p>' })
|
||||
const second = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>b</p>' })
|
||||
const installed = await service.install(first)
|
||||
await expect(service.install(first)).resolves.toMatchObject({ id: installed.id, version: installed.version })
|
||||
await expect(service.install(second)).rejects.toMatchObject({ code: 'version_conflict' })
|
||||
expect(await readdir(join(userData, 'report-templates', 'staging'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rebuilds a damaged index from installed manifests', async () => {
|
||||
const zip = await makeZip({ 'manifest.json': manifest({ templateVersion: '2.0.0' }), 'template.html': '<p>{{REPORT_TITLE}}</p>' })
|
||||
const installed = await service.install(zip)
|
||||
await writeFile(join(userData, 'report-templates', 'index.json'), '{broken', 'utf8')
|
||||
const recovered = new ReportTemplateService()
|
||||
await recovered.recover()
|
||||
expect((await recovered.list()).find((item) => item.id === installed.id && item.version === '2.0.0')?.entryPath).toContain('installed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user