mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 21:05:39 +08:00
feat: 日报新增模板市场,支持社区模板安装与使用
This commit is contained in:
@@ -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