114 lines
3.8 KiB
TypeScript
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');
|
|
});
|
|
});
|