Files
openmaic/OpenMAIC/tests/server/classroom-authz-route.test.ts
2026-08-16 14:58:47 +08:00

114 lines
3.8 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from 'vitest';
import { NextRequest } from 'next/server';
const mocks = vi.hoisted(() => ({
authorizeCreation: vi.fn(),
authorizeRequest: vi.fn(),
accessError: vi.fn(),
mayAccessRaw: vi.fn(),
persist: vi.fn(),
read: vi.fn(),
}));
vi.mock('@/lib/server/classroom-storage', () => ({
buildRequestOrigin: () => 'https://server.example',
isValidClassroomId: (id: unknown) => typeof id === 'string' && /^[\w-]+$/.test(id),
persistClassroom: mocks.persist,
readClassroom: mocks.read,
}));
vi.mock('@/lib/server/published-classroom-access', () => ({
mayAccessRawClassroom: mocks.mayAccessRaw,
}));
vi.mock('@/lib/server/authz/classroom-access', () => ({
authorizeClassroomCreation: mocks.authorizeCreation,
authorizeClassroomRequest: mocks.authorizeRequest,
classroomAccessError: mocks.accessError,
}));
function persisted(ownerPrincipalId = 'account-1') {
return {
id: 'classroom-1',
ownerPrincipalId,
ownershipBoundAt: '2026-08-16T00:00:00.000Z',
stage: { id: 'classroom-1', name: 'Existing', createdAt: 1, updatedAt: 1 },
scenes: [],
createdAt: '2026-08-16T00:00:00.000Z',
};
}
describe('raw classroom authorization route boundary', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.mayAccessRaw.mockResolvedValue(true);
mocks.authorizeCreation.mockResolvedValue({
allowed: true,
ownership: { ownerPrincipalId: 'account-1' },
});
mocks.authorizeRequest.mockResolvedValue({ allowed: true });
mocks.accessError.mockImplementation((denial) =>
Response.json({ success: false, errorCode: denial.code }, { status: denial.status }),
);
mocks.persist.mockResolvedValue({ id: 'classroom-1', url: '/classroom/classroom-1' });
mocks.read.mockResolvedValue(null);
});
test('binds server-derived ownership on first persistence', async () => {
const { POST } = await import('@/app/api/classroom/route');
const request = new NextRequest('https://server.example/api/classroom', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
stage: { id: 'classroom-1', name: 'New', createdAt: 1, updatedAt: 1 },
scenes: [],
}),
});
expect((await POST(request)).status).toBe(201);
expect(mocks.persist).toHaveBeenCalledWith(expect.any(Object), 'https://server.example', {
ownerPrincipalId: 'account-1',
});
});
test('preserves the existing owner and rejects before overwrite when access is denied', async () => {
const existing = persisted();
mocks.read.mockResolvedValue(existing);
mocks.authorizeRequest.mockResolvedValue({
allowed: false,
status: 403,
code: 'FORBIDDEN',
message: 'Denied',
});
const { POST } = await import('@/app/api/classroom/route');
const response = await POST(
new NextRequest('https://server.example/api/classroom', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
stage: { id: 'classroom-1', name: 'Overwrite', createdAt: 1, updatedAt: 1 },
scenes: [],
}),
}),
);
expect(response.status).toBe(403);
expect(mocks.persist).not.toHaveBeenCalled();
expect(mocks.authorizeCreation).not.toHaveBeenCalled();
});
test('denies reads before returning classroom content', async () => {
mocks.read.mockResolvedValue(persisted());
mocks.authorizeRequest.mockResolvedValue({
allowed: false,
status: 403,
code: 'FORBIDDEN',
message: 'Denied',
});
const { GET } = await import('@/app/api/classroom/route');
const response = await GET(
new NextRequest('https://server.example/api/classroom?id=classroom-1'),
);
expect(response.status).toBe(403);
await expect(response.json()).resolves.not.toHaveProperty('classroom');
});
});