167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { promises as fs } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import type { CoursewareRecord, CoursewareRepo } from '@/lib/courseware-repo/types';
|
|
import { createFileCoursewareRepo } from '@/lib/courseware-repo/store';
|
|
import {
|
|
hasPublishedCourseReceiptSource,
|
|
isPublishedClassroomSource,
|
|
mayAccessRawClassroom,
|
|
protectsPublishedClassroomSources,
|
|
} from '@/lib/server/published-classroom-access';
|
|
|
|
const noPublicationReceipt = async () => false;
|
|
|
|
function record(coursewareId: string, sourceClassroomId?: string): CoursewareRecord {
|
|
return {
|
|
coursewareId,
|
|
...(sourceClassroomId ? { sourceClassroomId } : {}),
|
|
version: 1,
|
|
title: 'Frozen module',
|
|
status: 'published',
|
|
publishedAt: '2026-08-15T00:00:00.000Z',
|
|
contentHash: 'a'.repeat(64),
|
|
byteSize: 1,
|
|
entryCount: 1,
|
|
sceneCount: 1,
|
|
quizSceneCount: 0,
|
|
knowledgeVersion: 1,
|
|
complete: true,
|
|
bundleUrl: '/bundle.zip',
|
|
storageKey: 'module/v1.zip',
|
|
};
|
|
}
|
|
|
|
function repo(records: CoursewareRecord[]): CoursewareRepo {
|
|
return {
|
|
nextVersion: vi.fn(),
|
|
saveRecord: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
getRecord: vi.fn(),
|
|
getLatestRecord: vi.fn(),
|
|
listRecords: vi.fn().mockResolvedValue(records),
|
|
listLatest: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe('published classroom source access', () => {
|
|
it('protects public-serving deployments but leaves the ops workbench available', () => {
|
|
expect(protectsPublishedClassroomSources('learner')).toBe(true);
|
|
expect(protectsPublishedClassroomSources('server')).toBe(true);
|
|
expect(protectsPublishedClassroomSources('ops')).toBe(false);
|
|
expect(protectsPublishedClassroomSources('all')).toBe(false);
|
|
});
|
|
|
|
it('recognizes both new private source metadata and legacy shared ids', async () => {
|
|
const records = repo([
|
|
record('course_public_module_1', 'classroom-private-source'),
|
|
record('legacy-classroom-id'),
|
|
]);
|
|
await expect(
|
|
isPublishedClassroomSource('classroom-private-source', records, noPublicationReceipt),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
isPublishedClassroomSource('legacy-classroom-id', records, noPublicationReceipt),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
isPublishedClassroomSource('unpublished-source', records, noPublicationReceipt),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it('uses the current ops receipt when the remote registry is not mounted locally', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'published-source-receipt-'));
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(dir, 'course-remote.json'),
|
|
JSON.stringify({
|
|
id: 'course-remote',
|
|
publication: {
|
|
receiptVersion: 1,
|
|
manifestVersion: 3,
|
|
publishedAt: '2026-08-15T00:00:00.000Z',
|
|
modules: [
|
|
{
|
|
index: 1,
|
|
sourceClassroomId: 'remote-source-classroom',
|
|
sourceRevisionHash: 'a'.repeat(64),
|
|
sourceSemanticHash: 'b'.repeat(64),
|
|
coursewareId: 'course_course-remote_module_1',
|
|
coursewareVersion: 2,
|
|
contentHash: 'c'.repeat(64),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const lookup = (classroomId: string) => hasPublishedCourseReceiptSource(classroomId, dir);
|
|
const emptyRegistry = repo([]);
|
|
|
|
await expect(
|
|
isPublishedClassroomSource('remote-source-classroom', emptyRegistry, lookup),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
isPublishedClassroomSource('ordinary-source-classroom', emptyRegistry, lookup),
|
|
).resolves.toBe(false);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails closed when an ops publication receipt is corrupt', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'published-source-receipt-corrupt-'));
|
|
try {
|
|
await fs.writeFile(path.join(dir, 'course-corrupt.json'), '{not-json', 'utf-8');
|
|
await expect(
|
|
hasPublishedCourseReceiptSource('ordinary-source-classroom', dir),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('blocks a published source for learners while preserving ops review', async () => {
|
|
const records = repo([record('course_public_module_1', 'classroom-private-source')]);
|
|
await expect(
|
|
mayAccessRawClassroom('classroom-private-source', {
|
|
role: 'learner',
|
|
repo: records,
|
|
receiptLookup: noPublicationReceipt,
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
mayAccessRawClassroom('classroom-private-source', {
|
|
role: 'ops',
|
|
repo: records,
|
|
receiptLookup: noPublicationReceipt,
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
mayAccessRawClassroom('ordinary-user-classroom', {
|
|
role: 'learner',
|
|
repo: records,
|
|
receiptLookup: noPublicationReceipt,
|
|
}),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
it('uses a fail-closed reverse lookup for the file registry', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'published-source-access-'));
|
|
try {
|
|
const records = createFileCoursewareRepo(dir);
|
|
await records.saveRecord(record('course_public_module_1', 'classroom-private-source'));
|
|
await expect(
|
|
isPublishedClassroomSource('classroom-private-source', records, noPublicationReceipt),
|
|
).resolves.toBe(true);
|
|
|
|
await fs.writeFile(path.join(dir, 'corrupt.json'), '{not-json', 'utf-8');
|
|
await expect(
|
|
isPublishedClassroomSource('ordinary-user-classroom', records, noPublicationReceipt),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|