Files
openmaic/OpenMAIC/tests/server/authorization-shadow-observer.test.ts
2026-08-16 14:58:47 +08:00

193 lines
5.4 KiB
TypeScript

import { describe, expect, test, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { ClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { observeClassroomJobAuthorization } from '@/lib/server/authz/classroom-job-shadow';
import { observeResourceAuthorization } from '@/lib/server/authz/shadow-observer';
const request = new NextRequest('https://server.example/api/generate-classroom/job-1');
function job(owner: string | undefined, includeOwner = true): ClassroomGenerationJob {
return {
id: 'job-1',
...(includeOwner ? { ownerPrincipalId: owner } : {}),
status: 'failed',
step: 'failed',
progress: 100,
message: 'Failed',
createdAt: '2026-08-15T00:00:00.000Z',
updatedAt: '2026-08-15T00:00:00.000Z',
inputSummary: {
requirementPreview: 'topic',
hasPdf: false,
pdfTextLength: 0,
pdfImageCount: 0,
},
input: { requirement: 'topic' },
scenesGenerated: 0,
};
}
describe('observe-only resource authorization', () => {
test('reports a decision through parameter-injected dependencies', async () => {
const observer = vi.fn();
await observeResourceAuthorization(
{
request,
resourceType: 'classroom-generation-job',
resourceId: 'job-1',
action: 'read',
ownership: { state: 'owned', ownerPrincipalId: 'principal-1' },
},
{
resolvePrincipal: async () => ({
state: 'authenticated',
principal: {
principalId: 'principal-1',
kind: 'user',
capabilities: [],
authMethod: 'session',
},
}),
observer,
},
);
expect(observer).toHaveBeenCalledWith({
resourceType: 'classroom-generation-job',
resourceId: 'job-1',
action: 'read',
principalState: 'authenticated',
ownershipState: 'owned',
decision: { effect: 'allow', reason: 'owner_match' },
});
});
test('contains observer and resolver failures', async () => {
await expect(
observeResourceAuthorization(
{
request,
resourceType: 'classroom-generation-job',
resourceId: 'job-1',
action: 'delete',
ownership: { state: 'legacy-unowned' },
},
{
resolvePrincipal: async () => ({
state: 'unavailable',
reason: 'resolver_not_configured',
}),
observer: () => {
throw new Error('observer failed');
},
},
),
).resolves.toBeUndefined();
await expect(
observeResourceAuthorization(
{
request,
resourceType: 'classroom-generation-job',
resourceId: 'job-1',
action: 'delete',
ownership: { state: 'legacy-unowned' },
},
{
resolvePrincipal: async () => {
throw new Error('resolver failed');
},
observer: vi.fn(),
},
),
).resolves.toBeUndefined();
});
test('classifies absent legacy owners and malformed owners as indeterminate', async () => {
const observations: unknown[] = [];
const deps = {
resolvePrincipal: async () => ({
state: 'unavailable' as const,
reason: 'resolver_not_configured' as const,
}),
observer: (observation: unknown) => {
observations.push(observation);
},
};
await observeClassroomJobAuthorization(request, job(undefined, false), 'read', deps);
await observeClassroomJobAuthorization(request, job(undefined), 'delete', deps);
expect(observations).toEqual([
expect.objectContaining({
ownershipState: 'legacy-unowned',
decision: { effect: 'indeterminate', reason: 'owner_missing' },
}),
expect.objectContaining({
ownershipState: 'unknown',
decision: { effect: 'indeterminate', reason: 'owner_unknown' },
}),
]);
});
test('classifies a temporary device binding as guest ownership', async () => {
const observer = vi.fn();
const guestJob = {
...job(undefined, false),
guestPrincipalId: 'device-1',
ownershipBoundAt: '2026-08-16T00:00:00.000Z',
};
await observeClassroomJobAuthorization(request, guestJob, 'read', {
resolvePrincipal: async () => ({
state: 'authenticated',
principal: {
principalId: 'device-1',
kind: 'device',
capabilities: [],
authMethod: 'device-proof',
},
}),
observer,
});
expect(observer).toHaveBeenCalledWith(
expect.objectContaining({
ownershipState: 'guest-owned',
decision: { effect: 'allow', reason: 'guest_match' },
}),
);
});
test('fails closed when a persisted job contains both owner and guest subjects', async () => {
const observer = vi.fn();
await observeClassroomJobAuthorization(
request,
{
...job('account-1'),
guestPrincipalId: 'device-1',
},
'read',
{
resolvePrincipal: async () => ({
state: 'authenticated',
principal: {
principalId: 'account-1',
kind: 'user',
capabilities: [],
authMethod: 'session',
},
}),
observer,
},
);
expect(observer).toHaveBeenCalledWith(
expect.objectContaining({
ownershipState: 'unknown',
decision: { effect: 'indeterminate', reason: 'owner_unknown' },
}),
);
});
});