639 lines
23 KiB
TypeScript
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;
|
|
}
|