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> | Promise>>, ) { 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; }