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

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