151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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');
|
|
});
|
|
});
|