193 lines
5.4 KiB
TypeScript
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' },
|
|
}),
|
|
);
|
|
});
|
|
});
|