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'); }); });