Files
openmaic/OpenMAIC/tests/courseware/storage-security.test.ts
2026-08-16 14:58:47 +08:00

219 lines
8.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { createFileCourseManifestRepo } from '@/lib/course-manifest-repo/store';
import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types';
import { createFileBundleByteStore } from '@/lib/courseware-repo/bundle-store';
import { createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import type { CoursewareRecord } from '@/lib/courseware-repo/types';
let tempRoot: string;
let recordsDir: string;
let bundlesDir: string;
let manifestsDir: string;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'courseware-storage-security-'));
recordsDir = path.join(tempRoot, 'records');
bundlesDir = path.join(tempRoot, 'bundles');
manifestsDir = path.join(tempRoot, 'manifests');
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
function record(coursewareId: string, version: number): CoursewareRecord {
return {
coursewareId,
version,
title: 'Storage boundary fixture',
status: 'published',
publishedAt: '2026-08-15T00:00:00.000Z',
contentHash: 'a'.repeat(64),
byteSize: 3,
entryCount: 1,
sceneCount: 1,
quizSceneCount: 0,
knowledgeVersion: 1,
complete: true,
bundleUrl: 'https://learn.example/bundle.zip',
storageKey: `${coursewareId}/v${version}.zip`,
};
}
function manifestRecord(courseId: string, version: number): CourseManifestRecord {
return {
schemaVersion: 2,
courseId,
version,
title: 'Manifest storage boundary fixture',
summary: 'Fixture',
modules: [
{
index: 1,
title: 'Module 1',
description: 'Fixture',
coursewareId: 'safe-courseware',
coursewareVersion: 1,
contentHash: 'a'.repeat(64),
},
],
publishedAt: '2026-08-15T00:00:00.000Z',
};
}
describe('courseware record storage path boundary', () => {
test('rejects invalid ids before every keyed repository operation performs IO', async () => {
const repo = createFileCoursewareRepo(recordsDir);
const invalidId = '../outside';
await expect(repo.nextVersion(invalidId)).rejects.toThrow('Invalid coursewareId');
await expect(repo.saveRecord(record(invalidId, 1))).rejects.toThrow('Invalid coursewareId');
await expect(repo.setStatus(invalidId, 1, 'published')).rejects.toThrow('Invalid coursewareId');
await expect(repo.getRecord(invalidId, 1)).rejects.toThrow('Invalid coursewareId');
await expect(repo.getLatestRecord(invalidId)).rejects.toThrow('Invalid coursewareId');
await expect(repo.hasPublishedSource?.(invalidId)).rejects.toThrow('Invalid coursewareId');
await expect(fs.stat(recordsDir)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tempRoot, 'outside.json'))).rejects.toMatchObject({
code: 'ENOENT',
});
});
test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
'rejects invalid record version %s before IO',
async (version) => {
const repo = createFileCoursewareRepo(recordsDir);
await expect(repo.saveRecord(record('safe-courseware', version))).rejects.toThrow(
'Invalid courseware version',
);
await expect(repo.setStatus('safe-courseware', version, 'published')).rejects.toThrow(
'Invalid courseware version',
);
await expect(repo.getRecord('safe-courseware', version)).rejects.toThrow(
'Invalid courseware version',
);
await expect(fs.stat(recordsDir)).rejects.toMatchObject({ code: 'ENOENT' });
},
);
test('keeps valid record files as direct children of the configured root', async () => {
const repo = createFileCoursewareRepo(recordsDir);
await repo.saveRecord(record('safe_courseware-1', 1));
expect(await fs.readdir(recordsDir)).toEqual(['safe_courseware-1.json']);
await expect(repo.getRecord('safe_courseware-1', 1)).resolves.toMatchObject({
coursewareId: 'safe_courseware-1',
version: 1,
});
});
});
describe('courseware bundle byte storage path boundary', () => {
test('rejects traversal ids for save, read, remove and storageKey without IO', async () => {
const store = createFileBundleByteStore(bundlesDir);
const invalidId = '../../outside';
await expect(store.save(invalidId, 1, new Uint8Array([1]))).rejects.toThrow(
'Invalid coursewareId',
);
await expect(store.read(invalidId, 1)).rejects.toThrow('Invalid coursewareId');
await expect(store.remove(invalidId, 1)).rejects.toThrow('Invalid coursewareId');
expect(() => store.storageKey(invalidId, 1)).toThrow('Invalid coursewareId');
await expect(fs.stat(bundlesDir)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tempRoot, 'outside'))).rejects.toMatchObject({ code: 'ENOENT' });
});
test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
'rejects invalid bundle version %s for every byte operation without IO',
async (version) => {
const store = createFileBundleByteStore(bundlesDir);
await expect(store.save('safe-courseware', version, new Uint8Array([1]))).rejects.toThrow(
'Invalid courseware version',
);
await expect(store.read('safe-courseware', version)).rejects.toThrow(
'Invalid courseware version',
);
await expect(store.remove('safe-courseware', version)).rejects.toThrow(
'Invalid courseware version',
);
expect(() => store.storageKey('safe-courseware', version)).toThrow(
'Invalid courseware version',
);
await expect(fs.stat(bundlesDir)).rejects.toMatchObject({ code: 'ENOENT' });
},
);
test('keeps valid bundle bytes under direct-child id and version paths', async () => {
const store = createFileBundleByteStore(bundlesDir);
const bytes = new Uint8Array([1, 2, 3]);
await expect(store.save('safe_courseware-1', 2, bytes)).resolves.toBe(
'safe_courseware-1/v2.zip',
);
expect(await fs.readdir(bundlesDir)).toEqual(['safe_courseware-1']);
expect(await fs.readdir(path.join(bundlesDir, 'safe_courseware-1'))).toEqual(['v2.zip']);
expect(Array.from((await store.read('safe_courseware-1', 2)) ?? [])).toEqual(Array.from(bytes));
await expect(store.remove('safe_courseware-1', 2)).resolves.toBeUndefined();
});
});
describe('course manifest storage path boundary', () => {
test('rejects traversal ids for every keyed operation before IO', async () => {
const repo = createFileCourseManifestRepo(manifestsDir);
const invalidId = '../../outside';
const invalidRecord = manifestRecord(invalidId, 1);
const { version: _version, ...invalidDraft } = invalidRecord;
await expect(repo.nextVersion(invalidId)).rejects.toThrow('Invalid course manifest id');
await expect(repo.saveRecord(invalidRecord)).rejects.toThrow('Invalid course manifest id');
await expect(repo.saveNextRecord(invalidDraft)).rejects.toThrow('Invalid course manifest id');
await expect(repo.getRecord(invalidId, 1)).rejects.toThrow('Invalid course manifest id');
await expect(repo.getLatestRecord(invalidId)).rejects.toThrow('Invalid course manifest id');
await expect(
repo.hasExactPin!(invalidId, {
index: 1,
coursewareId: 'safe-courseware',
coursewareVersion: 1,
contentHash: 'a'.repeat(64),
}),
).resolves.toBe(false);
await expect(fs.stat(manifestsDir)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tempRoot, 'outside.json'))).rejects.toMatchObject({
code: 'ENOENT',
});
});
test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
'rejects invalid manifest version %s before IO',
async (version) => {
const repo = createFileCourseManifestRepo(manifestsDir);
await expect(repo.saveRecord(manifestRecord('safe-course', version))).rejects.toThrow(
'Invalid course manifest version',
);
await expect(repo.getRecord('safe-course', version)).rejects.toThrow(
'Invalid course manifest version',
);
await expect(fs.stat(manifestsDir)).rejects.toMatchObject({ code: 'ENOENT' });
},
);
test('keeps valid manifest histories as direct children of the configured root', async () => {
const repo = createFileCourseManifestRepo(manifestsDir);
await repo.saveRecord(manifestRecord('safe_course-1', 1));
expect(await fs.readdir(manifestsDir)).toEqual(['safe_course-1.json']);
await expect(repo.getRecord('safe_course-1', 1)).resolves.toMatchObject({
courseId: 'safe_course-1',
version: 1,
});
});
});