154 lines
5.0 KiB
TypeScript
154 lines
5.0 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
after: vi.fn(),
|
|
authorizeCreation: vi.fn(),
|
|
authorizeList: vi.fn(),
|
|
accessError: vi.fn(),
|
|
createJob: vi.fn(),
|
|
listJobs: vi.fn(),
|
|
runJob: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('next/server', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('next/server')>();
|
|
return { ...actual, after: mocks.after };
|
|
});
|
|
|
|
vi.mock('nanoid', () => ({ nanoid: () => 'job-created' }));
|
|
|
|
vi.mock('@/lib/server/classroom-job-store', () => ({
|
|
createClassroomGenerationJob: mocks.createJob,
|
|
listClassroomGenerationJobs: mocks.listJobs,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/classroom-job-runner', () => ({
|
|
runClassroomGenerationJob: mocks.runJob,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/classroom-storage', () => ({
|
|
buildRequestOrigin: () => 'https://server.example',
|
|
}));
|
|
|
|
vi.mock('@/lib/server/authz/classroom-job-access', () => ({
|
|
authorizeClassroomJobCreation: mocks.authorizeCreation,
|
|
authorizeClassroomJobList: mocks.authorizeList,
|
|
classroomJobAccessError: mocks.accessError,
|
|
}));
|
|
|
|
function storedJob(id: string) {
|
|
return {
|
|
id,
|
|
status: 'queued',
|
|
step: 'queued',
|
|
progress: 0,
|
|
message: 'queued',
|
|
createdAt: '2026-08-16T00:00:00.000Z',
|
|
updatedAt: '2026-08-16T00:00:00.000Z',
|
|
inputSummary: {
|
|
requirementPreview: id,
|
|
hasPdf: false,
|
|
pdfTextLength: 0,
|
|
pdfImageCount: 0,
|
|
},
|
|
input: { requirement: id },
|
|
scenesGenerated: 0,
|
|
};
|
|
}
|
|
|
|
describe('generation job create/list authorization boundary', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
mocks.accessError.mockImplementation(
|
|
(denial: { status: number; code: string; message: string }) =>
|
|
Response.json(
|
|
{ success: false, errorCode: denial.code, error: denial.message },
|
|
{ status: denial.status },
|
|
),
|
|
);
|
|
mocks.authorizeCreation.mockResolvedValue({
|
|
allowed: true,
|
|
ownership: { ownerPrincipalId: 'account-1' },
|
|
});
|
|
mocks.createJob.mockResolvedValue(storedJob('job-created'));
|
|
mocks.listJobs.mockResolvedValue([storedJob('mine'), storedJob('other')]);
|
|
mocks.authorizeList.mockImplementation(async (_request, jobs) => ({
|
|
allowed: true,
|
|
jobs: [jobs[0]],
|
|
}));
|
|
});
|
|
|
|
test('binds the server-derived owner when creating a public generation job', async () => {
|
|
const { POST } = await import('@/app/api/generate-classroom/route');
|
|
const request = new NextRequest('https://server.example/api/generate-classroom', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ requirement: 'Algebra' }),
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const response = await POST(request);
|
|
|
|
expect(response.status).toBe(202);
|
|
expect(mocks.createJob).toHaveBeenCalledWith(
|
|
'job-created',
|
|
expect.objectContaining({ requirement: 'Algebra' }),
|
|
{ ownerPrincipalId: 'account-1' },
|
|
);
|
|
expect(mocks.after).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test('returns a required-mode denial before creating or scheduling work', async () => {
|
|
mocks.authorizeCreation.mockResolvedValue({
|
|
allowed: false,
|
|
status: 503,
|
|
code: 'IDENTITY_UNAVAILABLE',
|
|
message: 'Identity verification is unavailable.',
|
|
});
|
|
const { POST } = await import('@/app/api/generate-classroom/route');
|
|
const request = new NextRequest('https://server.example/api/generate-classroom', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ requirement: 'Algebra' }),
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
|
|
const response = await POST(request);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(mocks.createJob).not.toHaveBeenCalled();
|
|
expect(mocks.after).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns only the jobs selected by required-mode ownership filtering', async () => {
|
|
const { GET } = await import('@/app/api/generate-classroom/route');
|
|
const response = await GET(
|
|
new NextRequest('https://server.example/api/generate-classroom?limit=20'),
|
|
);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.items).toHaveLength(1);
|
|
expect(body.items[0].jobId).toBe('mine');
|
|
expect(mocks.authorizeList).toHaveBeenCalledWith(expect.any(NextRequest), expect.any(Array), {
|
|
mode: 'shadow',
|
|
});
|
|
});
|
|
|
|
test('loads before owner filtering and applies the user limit afterwards in required mode', async () => {
|
|
vi.stubEnv('OPENMAIC_IDENTITY_ENFORCEMENT', 'required');
|
|
const { GET } = await import('@/app/api/generate-classroom/route');
|
|
const response = await GET(
|
|
new NextRequest('https://server.example/api/generate-classroom?limit=1'),
|
|
);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mocks.listJobs).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER);
|
|
expect(mocks.authorizeList).toHaveBeenCalledWith(expect.any(NextRequest), expect.any(Array), {
|
|
mode: 'required',
|
|
});
|
|
expect(body.items).toHaveLength(1);
|
|
});
|
|
});
|