Files
openmaic/OpenMAIC/tests/bundle/repo.test.ts
2026-08-16 14:58:47 +08:00

639 lines
23 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { NextRequest } from 'next/server';
import { createAccessToken } from '@/lib/server/access-token';
import {
isPublishTokenConfigured,
publishBuiltCourseware,
publishCourseware,
verifyPublishToken,
} from '@/lib/courseware-repo';
import { createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import { createFileBundleByteStore } from '@/lib/courseware-repo/bundle-store';
import {
computeBundleContentHash,
packageCourseware,
readFrozenBundleDocuments,
} from '@/lib/bundle/packager';
import { AUDIO_BYTES, makeScenes, makeStage, packageSampleBundle } from './fixtures';
// ─── Environment ─────────────────────────────────────────────
let recordsDir: string;
let bytesDir: string;
beforeAll(async () => {
recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cw-records-'));
bytesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cw-bytes-'));
});
afterAll(async () => {
await fs.rm(recordsDir, { recursive: true, force: true });
await fs.rm(bytesDir, { recursive: true, force: true });
});
function makeRepos() {
return {
records: createFileCoursewareRepo(recordsDir),
bytes: createFileBundleByteStore(bytesDir),
};
}
// ─── Publish token ───────────────────────────────────────────
describe('publish token', () => {
const ENV = 'COURSEWARE_PUBLISH_TOKEN';
beforeEach(() => {
vi.unstubAllEnvs();
});
test('verifies a matching token and rejects others', () => {
vi.stubEnv(ENV, 'top-secret-token');
expect(isPublishTokenConfigured()).toBe(true);
expect(verifyPublishToken('top-secret-token')).toBe(true);
expect(verifyPublishToken('wrong')).toBe(false);
expect(verifyPublishToken(null)).toBe(false);
expect(verifyPublishToken('')).toBe(false);
});
test('refuses when the token env is not configured', () => {
vi.unstubAllEnvs();
vi.stubEnv(ENV, '');
expect(isPublishTokenConfigured()).toBe(false);
expect(verifyPublishToken('anything')).toBe(false);
});
});
// ─── publishCourseware ───────────────────────────────────────
async function zipBytesOf(
result:
| Awaited<ReturnType<typeof packageCourseware>>
| Promise<Awaited<ReturnType<typeof packageCourseware>>>,
) {
return new Uint8Array(await (await result).zip.arrayBuffer());
}
async function packageVariantBytes(options: {
coursewareId: string;
version: number;
title: string;
}) {
const stage = { ...makeStage(), id: options.coursewareId, name: options.title };
const scenes = makeScenes().map((scene) => ({ ...scene, stageId: options.coursewareId }));
return zipBytesOf(
packageCourseware({
coursewareId: options.coursewareId,
version: options.version,
stage,
scenes,
resolveAudioBytes: async (audioId) => (audioId === 'aud-1' ? AUDIO_BYTES : null),
publishedAt: '2026-08-15T00:00:00.000Z',
appVersion: 'test',
requireComplete: true,
}),
);
}
describe('publishCourseware', () => {
test('publishes a bundle and auto-increments versions', async () => {
const repos = makeRepos();
const first = await publishCourseware({
zipBytes: await zipBytesOf(packageSampleBundle({ version: 1 })),
coursewareId: 'cw-1',
baseUrl: 'http://localhost:3000',
repos,
});
expect(first.record).toMatchObject({
coursewareId: 'cw-1',
version: 1,
title: '示例课件:光合作用',
language: 'zh-CN',
status: 'published',
sceneCount: 3,
quizSceneCount: 1,
knowledgeVersion: 1,
complete: true,
});
expect(first.record.bundleUrl).toBe(
'http://localhost:3000/api/coursewares/cw-1/bundles/1/download',
);
expect(first.record.contentHash).toMatch(/^[0-9a-f]{64}$/);
expect(first.record.byteSize).toBeGreaterThan(0);
expect(first.record.entryCount).toBeGreaterThan(6);
// Bytes are persisted and readable back.
const stored = await repos.bytes.read('cw-1', 1);
expect(stored).not.toBeNull();
expect(await computeBundleContentHash(stored!)).toBe(first.record.contentHash);
// A browser-built second publish still carries the packager's default v1.
// The registry allocates v2 and stamps that exact identity into the stored
// ZIP while holding the same publish lock.
const second = await publishCourseware({
zipBytes: await zipBytesOf(packageSampleBundle({ version: 1 })),
coursewareId: 'cw-1',
baseUrl: 'http://localhost:3000',
repos,
});
expect(second.record.version).toBe(2);
expect(second.documents.meta.version).toBe(2);
expect(second.record.bundleUrl).toContain('/bundles/2/download');
expect((await repos.records.getLatestRecord('cw-1'))?.version).toBe(2);
const storedSecond = await repos.bytes.read('cw-1', 2);
expect(storedSecond).not.toBeNull();
expect((await readFrozenBundleDocuments(storedSecond!)).meta.version).toBe(2);
expect(await computeBundleContentHash(storedSecond!)).toBe(second.record.contentHash);
expect(
(await repos.records.listLatest({ status: 'published' })).map((r) => r.coursewareId),
).toEqual(['cw-1']);
});
test('rejects a tampered bundle (content hash mismatch)', async () => {
// Re-zip with a modified manifest: the zip parses fine, but the recomputed
// payload hash no longer matches the declared one.
const JSZip = (await import('jszip')).default;
const original = await packageSampleBundle();
const zip = await JSZip.loadAsync(await original.zip.arrayBuffer());
const manifest = JSON.parse(await zip.file('manifest.json')!.async('string')) as {
stage: { name: string };
};
manifest.stage.name = '被篡改的课件';
zip.file('manifest.json', JSON.stringify(manifest));
const tampered = new Uint8Array(await zip.generateAsync({ type: 'uint8array' }));
await expect(
publishCourseware({
zipBytes: tampered,
coursewareId: 'cw-1',
baseUrl: 'http://localhost:3000',
repos: makeRepos(),
}),
).rejects.toThrow(/hash mismatch/);
});
test('rejects an incomplete bundle', async () => {
const result = packageSampleBundle({ missingAudio: true });
await expect(
publishCourseware({
zipBytes: await zipBytesOf(result),
coursewareId: 'cw-1',
baseUrl: 'http://localhost:3000',
repos: makeRepos(),
}),
).rejects.toThrow(/incomplete/);
});
test('rejects a coursewareId mismatch', async () => {
const result = packageSampleBundle();
await expect(
publishCourseware({
zipBytes: await zipBytesOf(result),
coursewareId: 'other-id',
baseUrl: 'http://localhost:3000',
repos: makeRepos(),
}),
).rejects.toThrow(/mismatch/);
});
test('explicit version is honored', async () => {
const repos = makeRepos();
const result = await publishCourseware({
zipBytes: await zipBytesOf(packageSampleBundle({ version: 9 })),
coursewareId: 'cw-1',
baseUrl: 'http://localhost:3000',
version: 9,
repos,
});
expect(result.record.version).toBe(9);
});
test('does not overwrite an explicit immutable version with different content', async () => {
const repos = makeRepos();
const coursewareId = 'immutable-explicit-version';
const version = 41;
const firstBytes = await packageVariantBytes({
coursewareId,
version,
title: '不可变版本 A',
});
const first = await publishCourseware({
zipBytes: firstBytes,
coursewareId,
baseUrl: 'http://localhost:3000',
version,
repos,
});
const storedBeforeConflict = await repos.bytes.read(coursewareId, version);
const conflictingBytes = await packageVariantBytes({
coursewareId,
version,
title: '不可变版本 B',
});
await expect(
publishCourseware({
zipBytes: conflictingBytes,
coursewareId,
baseUrl: 'http://localhost:3000',
version,
repos,
}),
).rejects.toThrow(/already exists and is immutable/);
expect(await repos.records.getRecord(coursewareId, version)).toEqual(first.record);
expect(await repos.bytes.read(coursewareId, version)).toEqual(storedBeforeConflict);
expect(await computeBundleContentHash((await repos.bytes.read(coursewareId, version))!)).toBe(
first.record.contentHash,
);
});
test('repeating the same explicit version and content is idempotent', async () => {
const repos = makeRepos();
const coursewareId = 'idempotent-explicit-version';
const version = 23;
const zipBytes = await packageVariantBytes({
coursewareId,
version,
title: '幂等课件',
});
const first = await publishCourseware({
zipBytes,
coursewareId,
baseUrl: 'http://localhost:3000',
version,
repos,
});
const second = await publishCourseware({
zipBytes,
coursewareId,
baseUrl: 'http://localhost:3000',
version,
repos,
});
expect(second.record).toEqual(first.record);
expect(
(await repos.records.listRecords()).filter(
(record) => record.coursewareId === coursewareId && record.version === version,
),
).toHaveLength(1);
expect(new Uint8Array((await repos.bytes.read(coursewareId, version))!)).toEqual(zipBytes);
});
test('never acknowledges an explicit retry when immutable bundle bytes are missing or corrupt', async () => {
const repos = makeRepos();
const missingId = 'explicit-missing-bytes';
const missingVersion = 51;
const missingBytes = await packageVariantBytes({
coursewareId: missingId,
version: missingVersion,
title: 'Missing bytes fixture',
});
await publishCourseware({
zipBytes: missingBytes,
coursewareId: missingId,
baseUrl: 'http://localhost:3000',
version: missingVersion,
repos,
});
await repos.bytes.remove(missingId, missingVersion);
await expect(
publishCourseware({
zipBytes: missingBytes,
coursewareId: missingId,
baseUrl: 'http://localhost:3000',
version: missingVersion,
repos,
}),
).rejects.toThrow(/bundle bytes are missing/);
const corruptId = 'explicit-corrupt-bytes';
const corruptVersion = 52;
const corruptBytes = await packageVariantBytes({
coursewareId: corruptId,
version: corruptVersion,
title: 'Corrupt bytes fixture',
});
await publishCourseware({
zipBytes: corruptBytes,
coursewareId: corruptId,
baseUrl: 'http://localhost:3000',
version: corruptVersion,
repos,
});
await fs.writeFile(
path.join(bytesDir, corruptId, `v${corruptVersion}.zip`),
new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
);
await expect(
publishCourseware({
zipBytes: corruptBytes,
coursewareId: corruptId,
baseUrl: 'http://localhost:3000',
version: corruptVersion,
repos,
}),
).rejects.toThrow(/bundle bytes are corrupt/);
});
test('does not let an explicit idempotent retry change private ownership', async () => {
const repos = makeRepos();
const coursewareId = 'explicit-owned-courseware';
const version = 53;
const zipBytes = await packageVariantBytes({
coursewareId,
version,
title: 'Owned courseware fixture',
});
const base = {
zipBytes,
coursewareId,
baseUrl: 'http://localhost:3000',
version,
repos,
};
await publishCourseware({
...base,
courseId: 'course-a',
courseModuleIndex: 1,
sourceClassroomId: 'classroom-a',
});
await expect(
publishCourseware({
...base,
courseId: 'course-b',
courseModuleIndex: 1,
sourceClassroomId: 'classroom-a',
}),
).rejects.toThrow(/already exists and is immutable/);
await expect(
publishCourseware({
...base,
courseId: 'course-a',
courseModuleIndex: 1,
sourceClassroomId: 'classroom-b',
}),
).rejects.toThrow(/already exists and is immutable/);
});
test('publishBuiltCourseware allocates the version before building matching bundle meta', async () => {
const repos = makeRepos();
const coursewareId = 'server-built-versioned';
const builtVersions: number[] = [];
const publishBuilt = () =>
publishBuiltCourseware({
coursewareId,
baseUrl: 'http://localhost:3000',
repos,
build: async (version) => {
builtVersions.push(version);
return packageVariantBytes({
coursewareId,
version,
title: `服务端冻结课件 v${version}`,
});
},
});
const first = await publishBuilt();
const second = await publishBuilt();
expect(builtVersions).toEqual([1, 2]);
expect(first.record.version).toBe(1);
expect(first.documents.meta.version).toBe(first.record.version);
expect(second.record.version).toBe(2);
expect(second.documents.meta.version).toBe(second.record.version);
const storedSecond = await repos.bytes.read(coursewareId, second.record.version);
expect(storedSecond).not.toBeNull();
expect((await readFrozenBundleDocuments(storedSecond!)).meta.version).toBe(
second.record.version,
);
});
});
// ─── API routes ──────────────────────────────────────────────
describe('POST /api/coursewares (publish route)', () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
vi.stubEnv('COURSEWARE_DATA_DIR', recordsDir);
vi.stubEnv('COURSEWARE_BUNDLE_DIR', bytesDir);
vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', 'route-secret');
});
afterEach(() => {
vi.unstubAllEnvs();
});
async function postPublish(form: FormData, token?: string) {
const { POST } = await import('@/app/api/coursewares/route');
const request = new Request('http://localhost/api/coursewares', {
method: 'POST',
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
body: form,
});
return POST(request as unknown as NextRequest);
}
test('publishes with a valid token and serves list/detail/download', async () => {
const packaged = packageSampleBundle({ coursewareId: 'route-cw-1' });
const zipBytes = await zipBytesOf(packaged);
// Unauthorized without token config? No — token IS configured here; test wrong token first.
const unauthorized = await postPublish(buildForm(zipBytes, 'route-cw-1'), 'wrong-token');
expect(unauthorized.status).toBe(401);
const publish = await postPublish(buildForm(zipBytes, 'route-cw-1'), 'route-secret');
expect(publish.status).toBe(201);
const publishBody = (await publish.json()) as {
success: boolean;
record: { coursewareId: string; version: number; bundleUrl: string };
};
expect(publishBody.success).toBe(true);
expect(publishBody.record).toMatchObject({ coursewareId: 'route-cw-1', version: 1 });
// GET list
const { GET: listGet } = await import('@/app/api/coursewares/route');
const listResponse = await listGet(
new Request('http://localhost/api/coursewares') as unknown as NextRequest,
);
expect(listResponse.status).toBe(200);
const listBody = (await listResponse.json()) as { items: Array<{ coursewareId: string }> };
expect(listBody.items.some((i) => i.coursewareId === 'route-cw-1')).toBe(true);
// GET detail
const { GET: detailGet } = await import('@/app/api/coursewares/[id]/route');
const detailResponse = await detailGet(
new Request('http://localhost/api/coursewares/route-cw-1') as unknown as NextRequest,
{ params: Promise.resolve({ id: 'route-cw-1' }) },
);
expect(detailResponse.status).toBe(200);
const detailBody = (await detailResponse.json()) as { courseware: { bundleUrl: string } };
expect(detailBody.courseware.bundleUrl).toContain('/bundles/1/download');
// GET download — bytes match the uploaded ZIP
const { GET: downloadGet } =
await import('@/app/api/coursewares/[id]/bundles/[version]/download/route');
const downloadResponse = await downloadGet(
new Request(
'http://localhost/api/coursewares/route-cw-1/bundles/1/download',
) as unknown as NextRequest,
{ params: Promise.resolve({ id: 'route-cw-1', version: '1' }) },
);
expect(downloadResponse.status).toBe(200);
expect(downloadResponse.headers.get('content-type')).toBe('application/zip');
const downloaded = new Uint8Array(await downloadResponse.arrayBuffer());
expect(downloaded).toEqual(zipBytes);
});
test('uses the ACCESS_CODE ops session for an explicit production all deployment', async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'all');
vi.stubEnv('ACCESS_CODE', 'route-access-code');
vi.stubEnv('COURSEWARE_PUBLIC_BASE_URL', 'https://learn.example');
const coursewareId = 'route-cw-production-all';
const zipBytes = await zipBytesOf(packageSampleBundle({ coursewareId }));
const { POST } = await import('@/app/api/coursewares/route');
const response = await POST(
new NextRequest('https://ops.example/api/coursewares', {
method: 'POST',
headers: {
cookie: `openmaic_access=${createAccessToken('route-access-code')}`,
origin: 'https://ops.example',
'x-forwarded-host': 'attacker.example',
'x-forwarded-proto': 'https',
},
body: buildForm(zipBytes, coursewareId),
}),
);
expect(response.status).toBe(201);
await expect(response.json()).resolves.toMatchObject({
record: { bundleUrl: expect.stringMatching(/^https:\/\/learn\.example\//) },
});
});
test('canonicalizes a repeated browser upload to the allocated registry version', async () => {
const coursewareId = 'route-cw-browser-repeat';
const packaged = await packageSampleBundle({ coursewareId, version: 1 });
const zipBytes = await zipBytesOf(packaged);
const first = await postPublish(buildForm(zipBytes, coursewareId), 'route-secret');
expect(first.status).toBe(201);
expect((await first.json()).record.version).toBe(1);
const second = await postPublish(buildForm(zipBytes, coursewareId), 'route-secret');
expect(second.status).toBe(201);
const secondBody = (await second.json()) as {
record: { version: number; contentHash: string };
};
expect(secondBody.record.version).toBe(2);
expect(secondBody.record.contentHash).toBe(packaged.contentHash);
const { GET: downloadGet } =
await import('@/app/api/coursewares/[id]/bundles/[version]/download/route');
const downloadResponse = await downloadGet(
new Request(
`http://localhost/api/coursewares/${coursewareId}/bundles/2/download`,
) as unknown as NextRequest,
{ params: Promise.resolve({ id: coursewareId, version: '2' }) },
);
expect(downloadResponse.status).toBe(200);
const downloaded = new Uint8Array(await downloadResponse.arrayBuffer());
const documents = await readFrozenBundleDocuments(downloaded);
expect(documents.meta.version).toBe(2);
expect(documents.meta.contentHash).toBe(secondBody.record.contentHash);
expect(await computeBundleContentHash(downloaded)).toBe(secondBody.record.contentHash);
});
test('returns 401 without a valid token and 503 when token env is missing', async () => {
const packaged = packageSampleBundle();
const zipBytes = await zipBytesOf(packaged);
const { POST } = await import('@/app/api/coursewares/route');
const noToken = await POST(
new Request('http://localhost/api/coursewares', {
method: 'POST',
body: buildForm(zipBytes),
}) as unknown as NextRequest,
);
expect(noToken.status).toBe(401);
vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', '');
const { POST: postDisabled } = await import('@/app/api/coursewares/route');
const disabled = await postDisabled(
new Request('http://localhost/api/coursewares', {
method: 'POST',
headers: { authorization: 'Bearer route-secret' },
body: buildForm(zipBytes),
}) as unknown as NextRequest,
);
expect(disabled.status).toBe(503);
});
test('rejects a non-bundle upload', async () => {
const { POST } = await import('@/app/api/coursewares/route');
const junk = new Uint8Array([80, 75, 3, 4, 1, 2, 3, 4]); // zip-ish garbage
const response = await POST(
new Request('http://localhost/api/coursewares', {
method: 'POST',
headers: { authorization: 'Bearer route-secret' },
body: buildForm(junk),
}) as unknown as NextRequest,
);
expect(response.status).toBe(400);
});
test('rejects a traversal coursewareId before any repository path is created', async () => {
const JSZip = (await import('jszip')).default;
const packaged = await packageSampleBundle({ coursewareId: 'route-safe-template', version: 1 });
const zip = await JSZip.loadAsync(await packaged.zip.arrayBuffer());
const bundleFile = zip.file('bundle.json');
expect(bundleFile).not.toBeNull();
const escapedName = `${path.basename(bytesDir)}-escaped`;
const traversalId = `../${escapedName}`;
const bundleDocument = JSON.parse(await bundleFile!.async('string')) as {
meta: { coursewareId: string };
};
bundleDocument.meta.coursewareId = traversalId;
zip.file('bundle.json', JSON.stringify(bundleDocument, null, 2));
const maliciousBytes = await zip.generateAsync({ type: 'uint8array' });
const form = buildForm(maliciousBytes, traversalId);
form.set('version', '1');
const response = await postPublish(form, 'route-secret');
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: 'Invalid coursewareId',
});
await expect(fs.stat(path.join(path.dirname(bytesDir), escapedName))).rejects.toMatchObject({
code: 'ENOENT',
});
await expect(
fs.stat(path.join(path.dirname(recordsDir), `${escapedName}.json`)),
).rejects.toMatchObject({ code: 'ENOENT' });
});
});
function buildForm(zipBytes: Uint8Array, coursewareId = 'cw-1'): FormData {
const form = new FormData();
form.append(
'zip',
new File([zipBytes as unknown as BlobPart], 'bundle.zip', { type: 'application/zip' }),
);
form.append('coursewareId', coursewareId);
return form;
}