import { createHash } from 'node:crypto'; import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { saveLearningProjectArchive, safeLearningProjectArchiveFileName, } from '@electron/services/learning-project-download'; const archive = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); const archiveSha256 = createHash('sha256').update(archive).digest('hex'); const binding = { accountKey: 'a'.repeat(64), epoch: 1 }; let root = ''; function project(overrides: Record = {}) { return { id: 'project-1', name: '机械臂', summary: '项目介绍', cover: { url: '/api/learning/projects/project-1/media/cover', alt: '封面' }, tags: [], version: null, archiveBytes: archive.length, publishedAt: '2026-08-01T00:00:00Z', updatedAt: '2026-08-01T00:00:00Z', readmeMarkdown: '# 机械臂', archiveFileName: 'project.zip', archiveSha256, ...overrides, } as never; } describe('Learning project verified download', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'makelore-learning-project-')); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); it('streams, verifies and atomically saves a ZIP without returning its path', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, })); const destinationPath = join(root, '机械臂.zip'); await saveLearningProjectArchive({ project: project(), destinationPath, binding, fetchImpl, getAccessToken: vi.fn().mockResolvedValue('works-token'), isCurrentAccountBinding: () => true, apiBaseUrl: 'https://square.example', }); await expect(readFile(destinationPath)).resolves.toEqual(archive); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/projects/project-1/archive', expect.objectContaining({ headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, redirect: 'manual', }), ); }); it('downloads without comparing metadata or transport-reported archive sizes', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length + 100) }, })); const destinationPath = join(root, 'large-project.zip'); await saveLearningProjectArchive({ project: project({ archiveBytes: 512 * 1024 * 1024 + 1 }), destinationPath, binding, fetchImpl, getAccessToken: vi.fn().mockResolvedValue('works-token'), isCurrentAccountBinding: () => true, apiBaseUrl: 'https://square.example', }); await expect(readFile(destinationPath)).resolves.toEqual(archive); }); it('downloads when the archive response omits Content-Length', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip' }, })); const destinationPath = join(root, 'unknown-size-project.zip'); await saveLearningProjectArchive({ project: project({ archiveBytes: 1 }), destinationPath, binding, fetchImpl, getAccessToken: vi.fn().mockResolvedValue('works-token'), isCurrentAccountBinding: () => true, apiBaseUrl: 'https://square.example', }); await expect(readFile(destinationPath)).resolves.toEqual(archive); }); it('rejects an unsafe redirect and never forwards Bearer credentials to redirects', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 302, headers: { Location: 'https://storage.example/private.zip' }, })); await expect(saveLearningProjectArchive({ project: project(), destinationPath: join(root, 'project.zip'), binding, fetchImpl, getAccessToken: vi.fn().mockResolvedValue('works-token'), isCurrentAccountBinding: () => true, apiBaseUrl: 'https://square.example', })).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID' }); expect(fetchImpl).toHaveBeenCalledTimes(1); }); it('removes partial files after an integrity failure', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, })); await expect(saveLearningProjectArchive({ project: project({ archiveSha256: 'f'.repeat(64) }), destinationPath: join(root, 'project.zip'), binding, fetchImpl, getAccessToken: vi.fn().mockResolvedValue('works-token'), isCurrentAccountBinding: () => true, apiBaseUrl: 'https://square.example', })).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); await expect(readdir(root)).resolves.toEqual([]); }); it('normalizes untrusted archive names to a ZIP file name', () => { expect(safeLearningProjectArchiveFileName('../bad.exe', 'project:1')).toBe('Makelore-project-1.zip'); expect(safeLearningProjectArchiveFileName('机械臂.zip', 'project-1')).toBe('机械臂.zip'); }); });