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