189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
import type { ClassroomGenerationJob } from '@/lib/server/classroom-job-store';
|
|
import {
|
|
authorizeClassroomJobCreation,
|
|
authorizeClassroomJobList,
|
|
authorizeClassroomJobRequest,
|
|
CLASSROOM_JOB_MANAGE_ANY_CAPABILITY,
|
|
} from '@/lib/server/authz/classroom-job-access';
|
|
import { resolveIdentityEnforcementMode } from '@/lib/server/authz/identity-enforcement';
|
|
import type { PrincipalResolution } from '@/lib/server/authz/principal';
|
|
|
|
const request = new Request('https://server.example/api/generate-classroom/job-1');
|
|
|
|
function principal(
|
|
kind: 'user' | 'device' | 'ops' | 'service',
|
|
principalId: string,
|
|
options: { userId?: string; capabilities?: string[] } = {},
|
|
): PrincipalResolution {
|
|
return {
|
|
state: 'authenticated',
|
|
principal: {
|
|
principalId,
|
|
kind,
|
|
...(options.userId ? { userId: options.userId } : {}),
|
|
capabilities: options.capabilities ?? [],
|
|
authMethod:
|
|
kind === 'device'
|
|
? 'device-proof'
|
|
: kind === 'ops'
|
|
? 'ops-cookie'
|
|
: kind === 'service'
|
|
? 'service-bearer'
|
|
: 'session',
|
|
},
|
|
};
|
|
}
|
|
|
|
function job(
|
|
id: string,
|
|
ownership: { ownerPrincipalId?: string; guestPrincipalId?: string } = {},
|
|
): ClassroomGenerationJob {
|
|
return {
|
|
id,
|
|
...ownership,
|
|
status: 'failed',
|
|
step: 'failed',
|
|
progress: 100,
|
|
message: 'Failed',
|
|
createdAt: '2026-08-16T00:00:00.000Z',
|
|
updatedAt: '2026-08-16T00:00:00.000Z',
|
|
inputSummary: {
|
|
requirementPreview: 'topic',
|
|
hasPdf: false,
|
|
pdfTextLength: 0,
|
|
pdfImageCount: 0,
|
|
},
|
|
input: { requirement: 'topic' },
|
|
scenesGenerated: 0,
|
|
};
|
|
}
|
|
|
|
describe('identity enforcement mode', () => {
|
|
test('requires an explicit required value and otherwise remains shadow-compatible', () => {
|
|
expect(resolveIdentityEnforcementMode(undefined)).toBe('shadow');
|
|
expect(resolveIdentityEnforcementMode('shadow')).toBe('shadow');
|
|
expect(resolveIdentityEnforcementMode('unexpected')).toBe('shadow');
|
|
expect(resolveIdentityEnforcementMode(' REQUIRED ')).toBe('required');
|
|
});
|
|
});
|
|
|
|
describe('classroom job creation ownership', () => {
|
|
test('binds account and guest subjects without trusting ops/service kinds as owners', async () => {
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('user', 'account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { ownerPrincipalId: 'account-1' } });
|
|
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('device', 'device-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { guestPrincipalId: 'device-1' } });
|
|
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () =>
|
|
principal('device', 'device-1', { userId: 'account-1' }),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, ownership: { ownerPrincipalId: 'account-1' } });
|
|
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('ops', 'ops-1'),
|
|
}),
|
|
).resolves.toMatchObject({ allowed: false, status: 403, code: 'FORBIDDEN' });
|
|
});
|
|
|
|
test('fails closed only in required mode when identity is unavailable', async () => {
|
|
const resolvePrincipal = async () =>
|
|
({ state: 'unavailable', reason: 'resolver_not_configured' }) as const;
|
|
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, { mode: 'shadow', resolvePrincipal }),
|
|
).resolves.toEqual({ allowed: true, ownership: {} });
|
|
await expect(
|
|
authorizeClassroomJobCreation(request, { mode: 'required', resolvePrincipal }),
|
|
).resolves.toMatchObject({
|
|
allowed: false,
|
|
status: 503,
|
|
code: 'IDENTITY_UNAVAILABLE',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('classroom job resource enforcement', () => {
|
|
test('allows account owners and bound device proofs, but denies a different account', async () => {
|
|
const owned = job('job-1', { ownerPrincipalId: 'account-1' });
|
|
|
|
await expect(
|
|
authorizeClassroomJobRequest(request, owned, 'delete', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('user', 'account-1'),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
await expect(
|
|
authorizeClassroomJobRequest(request, owned, 'read', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () =>
|
|
principal('device', 'device-1', { userId: 'account-1' }),
|
|
}),
|
|
).resolves.toEqual({ allowed: true });
|
|
await expect(
|
|
authorizeClassroomJobRequest(request, owned, 'read', {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('user', 'account-2'),
|
|
}),
|
|
).resolves.toMatchObject({ allowed: false, status: 403, code: 'FORBIDDEN' });
|
|
});
|
|
|
|
test('keeps shadow responses unchanged and records the future decision', async () => {
|
|
const observer = vi.fn();
|
|
await expect(
|
|
authorizeClassroomJobRequest(
|
|
request,
|
|
job('job-shadow', { ownerPrincipalId: 'account-1' }),
|
|
'read',
|
|
{
|
|
mode: 'shadow',
|
|
resolvePrincipal: async () => principal('user', 'account-2'),
|
|
observer,
|
|
},
|
|
),
|
|
).resolves.toEqual({ allowed: true });
|
|
expect(observer).toHaveBeenCalledWith(
|
|
expect.objectContaining({ decision: { effect: 'deny', reason: 'owner_mismatch' } }),
|
|
);
|
|
});
|
|
|
|
test('filters list results by owner and gives only an exact capability the global view', async () => {
|
|
const jobs = [
|
|
job('mine', { ownerPrincipalId: 'account-1' }),
|
|
job('other', { ownerPrincipalId: 'account-2' }),
|
|
job('legacy'),
|
|
];
|
|
|
|
await expect(
|
|
authorizeClassroomJobList(request, jobs, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () => principal('user', 'account-1'),
|
|
}),
|
|
).resolves.toMatchObject({ allowed: true, jobs: [{ id: 'mine' }] });
|
|
|
|
await expect(
|
|
authorizeClassroomJobList(request, jobs, {
|
|
mode: 'required',
|
|
resolvePrincipal: async () =>
|
|
principal('ops', 'ops-1', {
|
|
capabilities: [CLASSROOM_JOB_MANAGE_ANY_CAPABILITY],
|
|
}),
|
|
}),
|
|
).resolves.toEqual({ allowed: true, jobs });
|
|
});
|
|
});
|