219 lines
8.5 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|