feat(learning): replace courses with project catalog
This commit is contained in:
110
tests/unit/learning-project-download.test.ts
Normal file
110
tests/unit/learning-project-download.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user