Files
makelore/tests/unit/learning-project-download.test.ts

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');
});
});