feat: productionize learning engine and classroom
This commit is contained in:
@@ -4,6 +4,7 @@ import { middleware } from '@/middleware';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
@@ -54,7 +55,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
expect(response.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
|
||||
test('allows a single-course generation POST while hiding the ownerless global job list', async () => {
|
||||
test('hides both single-course generation POST and its ownerless global job list', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
|
||||
|
||||
@@ -65,7 +66,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
new NextRequest('https://server.example/api/generate-classroom'),
|
||||
);
|
||||
|
||||
expect(createResponse.headers.get('x-middleware-next')).toBe('1');
|
||||
expect(createResponse.status).toBe(404);
|
||||
expect(listResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -98,7 +99,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
expect(response.headers.get('x-middleware-next')).toBeNull();
|
||||
});
|
||||
|
||||
test('does not preempt a server publish request authenticated by Bearer at the route', async () => {
|
||||
test('does not expose legacy publish APIs even when a caller supplies a Bearer', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
|
||||
vi.stubEnv('ACCESS_CODE', 'shared-compose-access-code');
|
||||
@@ -110,8 +111,8 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-next')).toBe('1');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('x-middleware-next')).toBeNull();
|
||||
});
|
||||
|
||||
test('preserves learner handling for ordinary and operations paths', async () => {
|
||||
@@ -129,7 +130,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
expect(opsApi.status).toBe(403);
|
||||
});
|
||||
|
||||
test('preserves operations workbench routing', async () => {
|
||||
test('serves the operations shell but fails closed for APIs without Works identity config', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', '');
|
||||
@@ -138,6 +139,43 @@ describe('ACCESS_CODE middleware deployment boundary', () => {
|
||||
const api = await middleware(new NextRequest('https://ops.example/api/courses'));
|
||||
|
||||
expect(page.headers.get('x-middleware-next')).toBe('1');
|
||||
expect(api.headers.get('x-middleware-next')).toBe('1');
|
||||
expect(api.status).toBe(503);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'/api/generate-classroom',
|
||||
'/api/quiz-grade',
|
||||
'/api/pbl/v2/instructor',
|
||||
])('requires an introspected Works admin for every ops API: %s', async (pathname) => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('WORKS_SQUARE_API_BASE_URL', 'https://works.example');
|
||||
vi.stubEnv('OPS_PUBLIC_ORIGIN', 'https://ops.example');
|
||||
|
||||
const unauthorized = await middleware(new NextRequest(`https://ops.example${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: { origin: 'https://ops.example' },
|
||||
}));
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const introspection = vi.fn(async () => Response.json({
|
||||
username: 'jiaoyuop',
|
||||
site_role: 'admin',
|
||||
}));
|
||||
vi.stubGlobal('fetch', introspection);
|
||||
const authorized = await middleware(new NextRequest(`https://ops.example${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://ops.example',
|
||||
authorization: 'Bearer works-session',
|
||||
},
|
||||
}));
|
||||
expect(authorized.headers.get('x-middleware-next')).toBe('1');
|
||||
expect(introspection).toHaveBeenCalledWith(
|
||||
'https://works.example/api/auth/me',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer works-session' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,4 +308,57 @@ describe('job resume', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resume refuses a succeeded job without mutating its frozen source result', async () => {
|
||||
const store = await importStore();
|
||||
const runner = await importRunner();
|
||||
|
||||
await store.createClassroomGenerationJob('job-succeeded', INPUT);
|
||||
await store.updateClassroomGenerationJob('job-succeeded', {
|
||||
status: 'succeeded',
|
||||
step: 'completed',
|
||||
progress: 100,
|
||||
message: 'Classroom generation completed',
|
||||
result: {
|
||||
classroomId: 'frozen-classroom',
|
||||
url: 'http://localhost/classroom/frozen-classroom',
|
||||
scenesCount: 2,
|
||||
},
|
||||
});
|
||||
const before = await store.readClassroomGenerationJob('job-succeeded');
|
||||
|
||||
expect(
|
||||
await runner.resumeClassroomGenerationJob('job-succeeded', 'http://localhost'),
|
||||
).toBe(false);
|
||||
expect(await store.readClassroomGenerationJob('job-succeeded')).toEqual(before);
|
||||
expect(mocks.generateClassroom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resumes a failed job from its persisted input', async () => {
|
||||
const store = await importStore();
|
||||
const runner = await importRunner();
|
||||
|
||||
await store.createClassroomGenerationJob('job-failed-resume', INPUT);
|
||||
await store.markClassroomGenerationJobFailed('job-failed-resume', 'provider failed');
|
||||
mocks.generateClassroom.mockResolvedValueOnce({
|
||||
id: 'recovered-classroom',
|
||||
url: 'http://localhost/classroom/recovered-classroom',
|
||||
scenesCount: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
await runner.resumeClassroomGenerationJob('job-failed-resume', 'http://localhost'),
|
||||
).toBe(true);
|
||||
expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: 'Classroom generation job queued (resumed)',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({
|
||||
status: 'succeeded',
|
||||
result: { classroomId: 'recovered-classroom' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,22 +12,22 @@ describe('requireOpsAccess', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'learner');
|
||||
|
||||
const response = requireOpsAccess(new NextRequest('http://localhost/api/courses'));
|
||||
const response = await requireOpsAccess(new NextRequest('http://localhost/api/courses'));
|
||||
expect(response?.status).toBe(403);
|
||||
await expect(response?.json()).resolves.toMatchObject({ errorCode: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('fails closed when a production ops deployment has no access code', () => {
|
||||
it('fails closed when a production ops deployment has no Works identity origin', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', '');
|
||||
|
||||
const response = requireOpsAccess(new NextRequest('http://localhost/api/courses'));
|
||||
const response = await requireOpsAccess(new NextRequest('http://localhost/api/courses'));
|
||||
expect(response?.status).toBe(503);
|
||||
});
|
||||
|
||||
it('accepts a valid access-code session on an ops deployment', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
it('accepts a valid access-code session only for development compatibility', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
const token = createAccessToken('test-access-code');
|
||||
@@ -35,11 +35,11 @@ describe('requireOpsAccess', () => {
|
||||
headers: { cookie: `openmaic_access=${token}` },
|
||||
});
|
||||
|
||||
expect(requireOpsAccess(request)).toBeNull();
|
||||
await expect(requireOpsAccess(request)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a same-origin mutation with a valid ops session', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
it('accepts a same-origin mutation with a development ops session', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
const token = createAccessToken('test-access-code');
|
||||
@@ -51,17 +51,17 @@ describe('requireOpsAccess', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(requireOpsAccess(request)).toBeNull();
|
||||
await expect(requireOpsAccess(request)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects missing or cross-origin mutation origins', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
const token = createAccessToken('test-access-code');
|
||||
|
||||
for (const origin of [undefined, 'https://attacker.example']) {
|
||||
const response = requireOpsAccess(
|
||||
const response = await requireOpsAccess(
|
||||
new NextRequest('https://ops.example/api/courses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -76,7 +76,7 @@ describe('requireOpsAccess', () => {
|
||||
});
|
||||
|
||||
it('uses a configured public origin and never trusts forged forwarded hosts', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
vi.stubEnv('ACCESS_CODE_TRUST_PROXY_HEADERS', 'true');
|
||||
@@ -92,7 +92,7 @@ describe('requireOpsAccess', () => {
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(requireOpsAccess(accepted)).toBeNull();
|
||||
await expect(requireOpsAccess(accepted)).resolves.toBeNull();
|
||||
|
||||
const rejected = new NextRequest('http://internal:3000/api/courses', {
|
||||
method: 'POST',
|
||||
@@ -103,18 +103,18 @@ describe('requireOpsAccess', () => {
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
const response = requireOpsAccess(rejected);
|
||||
const response = await requireOpsAccess(rejected);
|
||||
expect(response?.status).toBe(403);
|
||||
await expect(response?.json()).resolves.toMatchObject({ errorCode: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('fails closed when the configured public origin is malformed', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
it('fails closed when the configured public origin is malformed', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
vi.stubEnv('OPS_PUBLIC_ORIGIN', 'https://ops.example/path');
|
||||
const token = createAccessToken('test-access-code');
|
||||
const response = requireOpsAccess(
|
||||
const response = await requireOpsAccess(
|
||||
new NextRequest('https://ops.example/api/courses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -126,8 +126,8 @@ describe('requireOpsAccess', () => {
|
||||
expect(response?.status).toBe(403);
|
||||
});
|
||||
|
||||
it('keeps production role=all aligned with the ops session contract', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
it('keeps development role=all aligned with the ops session contract', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'all');
|
||||
vi.stubEnv('ACCESS_CODE', 'test-access-code');
|
||||
const token = createAccessToken('test-access-code');
|
||||
@@ -139,6 +139,6 @@ describe('requireOpsAccess', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(requireOpsAccess(request)).toBeNull();
|
||||
await expect(requireOpsAccess(request)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user