feat: 日报新增模板市场,支持社区模板安装与使用

This commit is contained in:
Wxw-Gu
2026-09-08 11:37:38 +08:00
parent 1156362c3c
commit 976aac32c5
35 changed files with 2957 additions and 82 deletions
+49 -1
View File
@@ -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()
})
})
+282
View File
@@ -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 })
}
})
+600
View File
@@ -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 })
}
})
+22
View File
@@ -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',
+274
View File
@@ -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()
}
})
})
+126
View File
@@ -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')
})
})