117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
|
|
import type { PrincipalResolution } from '@/lib/server/authz/principal';
|
|
import {
|
|
authorizeClassroomCreation,
|
|
authorizeClassroomRequest,
|
|
} from '@/lib/server/authz/classroom-access';
|
|
|
|
const request = new Request('https://server.example/api/classroom');
|
|
|
|
function principal(
|
|
principalId: string,
|
|
kind: 'user' | 'device' = 'user',
|
|
userId?: string,
|
|
): PrincipalResolution {
|
|
return {
|
|
state: 'authenticated',
|
|
principal: {
|
|
principalId,
|
|
kind,
|
|
...(userId ? { userId } : {}),
|
|
capabilities: [],
|
|
authMethod: kind === 'device' ? 'device-proof' : 'session',
|
|
},
|
|
};
|
|
}
|
|
|
|
function classroom(
|
|
ownership: { ownerPrincipalId?: string; guestPrincipalId?: string } = {},
|
|
): PersistedClassroomData {
|
|
return {
|
|
id: 'classroom-1',
|
|
...ownership,
|
|
stage: { id: 'classroom-1', name: 'Classroom', createdAt: 1, updatedAt: 1 },
|
|
scenes: [],
|
|
createdAt: '2026-08-16T00:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
describe('classroom account ownership policy', () => {
|
|
test('binds users permanently and unbound devices as migratable guests', async () => {
|
|
await expect(
|
|
authorizeClassroomCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { ownerPrincipalId: 'account-1' } });
|
|
|
|
await expect(
|
|
authorizeClassroomCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('device-1', 'device'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { guestPrincipalId: 'device-1' } });
|
|
|
|
await expect(
|
|
authorizeClassroomCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('device-1', 'device', 'account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { ownerPrincipalId: 'account-1' } });
|
|
});
|
|
|
|
test('allows account owners and bound devices, but denies a different account', async () => {
|
|
const owned = classroom({ ownerPrincipalId: 'account-1' });
|
|
await expect(
|
|
authorizeClassroomRequest(request, owned, 'read', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
await expect(
|
|
authorizeClassroomRequest(request, owned, 'write', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('device-1', 'device', 'account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
await expect(
|
|
authorizeClassroomRequest(request, owned, 'read', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('account-2'),
|
|
}),
|
|
).resolves.toMatchObject({ allowed: false, status: 403, code: 'FORBIDDEN' });
|
|
});
|
|
|
|
test('fails closed for legacy or corrupt ownership in required mode', async () => {
|
|
const deps = {
|
|
mode: 'required' as const,
|
|
resolvePrincipal: async () => principal('account-1'),
|
|
};
|
|
await expect(
|
|
authorizeClassroomRequest(request, classroom(), 'read', deps),
|
|
).resolves.toMatchObject({ allowed: false, status: 409, code: 'OWNERSHIP_MIGRATION_REQUIRED' });
|
|
await expect(
|
|
authorizeClassroomRequest(
|
|
request,
|
|
classroom({ ownerPrincipalId: 'account-1', guestPrincipalId: 'device-1' }),
|
|
'read',
|
|
deps,
|
|
),
|
|
).resolves.toMatchObject({
|
|
allowed: false,
|
|
status: 409,
|
|
code: 'OWNERSHIP_MIGRATION_REQUIRED',
|
|
});
|
|
});
|
|
|
|
test('keeps current desktop behavior in explicit shadow mode', async () => {
|
|
await expect(
|
|
authorizeClassroomRequest(request, classroom({ ownerPrincipalId: 'other' }), 'write', {
|
|
mode: 'shadow',
|
|
resolvePrincipal: async () => ({ state: 'unavailable', reason: 'resolver_not_configured' }),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
});
|
|
});
|